
Veröffentlicht im Jahr 2015 war die Geburtsstunde eines neuen Genres , dessen PopularitÀt seitdem stark gestiegen ist. Ich habe den Anstieg der PopularitÀt von .io-Spielen selbst erlebt: In den letzten drei Jahren habe ich .
Falls Sie vorher noch nie von solchen Spielen gehört haben: Es handelt sich um kostenlose, mehrspieler-fÀhige Webspiele, an denen man leicht teilnehmen kann (kein Benutzerkonto erforderlich). Normalerweise treten viele gegnerische Spieler in einer Arena gegeneinander an. Weitere bekannte Spiele im .io-Genre sind: und .
In diesem Beitrag werden wir untersuchen, wie man ein .io-Spiel von Grund auf erstellt. Dazu benötigen Sie lediglich Kenntnisse in Javascript: Sie mĂŒssen Konzepte wie die Syntax , das SchlĂŒsselwort this und . Selbst wenn Sie Javascript nicht vollstĂ€ndig beherrschen, werden Sie dennoch in der Lage sein, den GroĂteil des Beitrags zu verstehen.
Beispiel eines .io-Spiels
Zur UnterstĂŒtzung beim Lernen werden wir auf verweisen. Versuchen Sie, es zu spielen!

Das Spiel ist recht einfach: Sie steuern ein Schiff in einer Arena, in der sich andere Spieler befinden. Ihr Schiff feuert automatisch Projektile ab, und Sie versuchen, andere Spieler zu treffen, wÀhrend Sie gleichzeitig ihren Geschossen ausweichen.
1. Kurze Ăbersicht/Projektstruktur
Ich empfehle Ihnen, den Quellcode Im Beispiel werden die folgenden Technologien verwendet:
Express
- socket.io
- Webpack
- So sieht die Verzeichnisstruktur des Projekts aus: .
public/
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.jsAlles im Ordner
wird statisch vom Server bereitgestellt. In Alles im Ordner public/assets/ befinden sich die in unserem Projekt verwendeten Bilder. src/
Der gesamte Quellcode befindet sich im Ordner
. Die Bezeichnungen Der gesamte Quellcode befindet sich im Ordnerclient/ server/ und sprechen fĂŒr sich, wĂ€hrend shared/ eine Konstante-Datei enthĂ€lt, die sowohl vom Client als auch vom Server importiert wird. 2. Builds/Projektparameter
Wie bereits erwÀhnt, verwenden wir den Modul-Manager
. Lassen Sie uns einen Blick auf unsere Webpack-Konfiguration werfen: webpack.common.js:
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 folgenden Zeilen sind hier am wichtigsten:
src/client/index.jsâ dies ist der Einstiegspunkt fĂŒr den Javascript (JS) Client. Webpack beginnt hier und sucht rekursiv nach anderen importierten Dateien.- Der Ausgangs-JS unserer Webpack-BĂŒndelung wird im Verzeichnis
dist/liegen. Ich werde diese Datei unser JS-Paket. - Wir verwenden , insbesondere die Konfiguration zur Transpilierung unseres JS-Codes fĂŒr alte Browser.
- Wir verwenden ein Plugin, um alle CSS-Dateien, auf die in den JS-Dateien verwiesen wird, zu extrahieren und sie an einem Ort zusammenzufĂŒhren. Ich werde es unser CSS-Paket.
genannt haben. Sie haben vielleicht die seltsamen Dateinamen der Pakete bemerkt '[name].[contenthash].ext',die Webpack: [name] wird durch den Namen des Einstiegspunkts ersetzt (in unserem Fall game), und [contenthash] wird durch den Hash des Dateiinhalts ersetzt. Wir machen dies, um â so können wir den Browsern befehlen, unsere JS-Pakete unbegrenzt zwischenzuspeichern, da sich der Paketname Ă€ndert, wenn sich der Inhalt Ă€ndert (das contenthash). Das Endergebnis wird ein Dateiname im Format game.dbeee76e91a97d0c7207.js.
Die Datei webpack.common.js ist die grundlegende Konfigurationsdatei, die wir in unseren Entwicklungs- und Produktionskonfigurationen importieren. Hier ist beispielsweise die Entwicklungs-konfiguration:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); Zur Steigerung der Effizienz verwenden wir im Entwicklungsprozess webpack.dev.js, und wechselt zu webpack.prod.js, um die PaketgröĂen bei der Bereitstellung fĂŒr die Produktion zu optimieren.
Lokale Einrichtung
Ich empfehle, das Projekt auf einem lokalen Computer zu installieren, damit Sie die Schritte in diesem Beitrag verfolgen können. Die Einrichtung ist einfach: ZunĂ€chst mĂŒssen und installiert sein. FĂŒhren Sie dann aus
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installund Sie sind bereit zu arbeiten! Um den Entwicklungsserver zu starten, genĂŒgt es,
$ npm run developund im Webbrowser zu gehen zu . Der Entwicklungsserver wird automatisch die JS- und CSS-Pakete neu kompilieren, wĂ€hrend Sie den Code Ă€ndern â aktualisieren Sie einfach die Seite, um alle Ănderungen zu sehen!
3. Client-Einstiegspunkte
Lassen Sie uns mit dem eigentlichen Spielcode beginnen. ZunÀchst benötigen wir die Seite index.html, die der Browser beim Besuch der Website zuerst laden wird. Unsere Seite wird ziemlich einfach sein:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Ein Beispiel .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="username-input" placeholder="Benutzername" />
<button id="play-button">SPIELEN</button>
</div>
</body>
</html>Dieses Codebeispiel ist leicht vereinfacht, damit es verstÀndlicher ist, das Gleiche werde ich auch mit vielen anderen Beispielen im Post machen. den vollstÀndigen Code finden Sie immer auf .
Wir haben:
- (
<canvas>), das wir zum Rendern des Spiels verwenden werden. <link>zum HinzufĂŒgen unseres CSS-Pakets.<script>zum HinzufĂŒgen unseres Javascript-Pakets.- Das HauptmenĂŒ mit dem Benutzernamen
<input>und einer âSPIELENâ-Taste ().
Nachdem die Startseite im Browser geladen wurde, wird der Javascript-Code ausgefĂŒhrt, beginnend mit der JS-Einstiegsdatei: 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 = () => {
// Spielen!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});Das mag kompliziert erscheinen, aber tatsÀchlich geschieht hier nicht viel:
- Import von mehreren anderen JS-Dateien.
- Import von CSS (damit Webpack weiĂ, dass es in unser CSS-Paket aufgenommen werden muss).
- Starten
connect()um eine Verbindung zum Server herzustellen unddownloadAssets()um die Bilder herunterzuladen, die fĂŒr das Rendern des Spiels erforderlich sind. - Nachdem Schritt 3 abgeschlossen ist, wird das HauptmenĂŒ angezeigt (
playMenu). - Einrichten des Handlers fĂŒr die SchaltflĂ€che âSPIELENâ. Wenn die SchaltflĂ€che gedrĂŒckt wird, initialisiert der Code das Spiel und teilt dem Server mit, dass wir bereit sind zu spielen.
Das Haupt âFleischâ unserer Client-Server-Logik befindet sich in den Dateien, die von der Datei importiert wurden index.js. Jetzt werden wir sie der Reihe nach durchgehen.
4. Datenaustausch zwischen Client
In diesem Spiel verwenden wir eine gut bekannte Bibliothek zur Kommunikation mit dem Server. Socket.io bietet integrierte UnterstĂŒtzung fĂŒr , die sich gut fĂŒr die bidirektionale Kommunikation eignen: Wir können Nachrichten an den Server senden, und der Server kann uns ĂŒber dieselbe Verbindung Nachrichten senden.
Wir werden eine Datei haben, src/client/networking.js, die sich um alle Kommunikationen mit dem Server kĂŒmmert:
networking.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('Mit dem Server verbunden!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
\/\/ Registriere 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 zur Verdeutlichung ebenfalls etwas kĂŒrzer.
In dieser Datei gibt es drei Hauptaktionen:
- Wir versuchen, eine Verbindung zum Server herzustellen.
connectedPromisewird nur aufgelöst, wenn wir die Verbindung hergestellt haben. - Wenn die Verbindung erfolgreich hergestellt wird, registrieren wir die Callback-Funktionen (
processGameUpdate()undonGameOver()) fĂŒr die Nachrichten, die wir vom Server empfangen können. - Wir exportieren
play()undupdateDirection(), damit sie von anderen Dateien verwendet werden können.
5. Rendering des Clients
Es ist Zeit, das Bild auf dem Bildschirm anzuzeigen!
⊠aber bevor wir das tun können, mĂŒssen wir alle Bilder (Ressourcen) herunterladen, die dafĂŒr nötig sind. Lass uns einen Ressourcenmanager schreiben:
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(`Download von ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `\/assets\/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]; Das Ressourcenmanagement ist nicht so kompliziert! Es geht darum, ein Objekt zu speichern, assets, das den Dateinamen als SchlĂŒssel mit dem Wert des Objekts verknĂŒpft. Bild. Wenn die Ressource heruntergeladen ist, speichern wir sie im Objekt assets fĂŒr einen schnellen Zugriff in der Zukunft. Sobald das Herunterladen jeder einzelnen Ressource (d.h. alle Ressourcen) abgeschlossen ist, lösen wir downloadPromise Nach dem Herunterladen der Ressourcen können wir mit dem Rendering beginnen. Wie bereits erwĂ€hnt, verwenden wir zum Zeichnen auf der Webseite.
HTML5 Canvas (<canvas>). Unser Spiel ist ziemlich einfach, daher reicht es aus, nur Folgendes zu rendern:
- Hintergrund
- Spielerschiff
- Andere Spieler im Spiel
- Geschosse
Hier sind die wichtigen Fragmente src/client/render.js, die genau die oben genannten vier 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;
// Erhalte den Grafik-Kontext des Canvas
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Mache das Canvas im Vollbildmodus
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Zeichne Hintergrund
renderBackground(me.x, me.y);
// Zeichne alle Geschosse
bullets.forEach(renderBullet.bind(null, me));
// Zeichne alle Spieler
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Hilfsfunktionen hier ausgeschlossen
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Dieser Code ist ebenfalls verkĂŒrzt fĂŒr die VerstĂ€ndlichkeit.
render() â die Hauptfunktion dieser Datei. startRendering() und stopRendering() steuern die Aktivierung der Renderingschleife mit 60 FPS.
Spezifische Implementierungen einzelner Hilfsfunktionen fĂŒr das Rendering (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(), die wir zuvor in asset.js!
Wenn Sie interessiert sind, andere Hilfsfunktionen fĂŒr das Rendering zu erforschen, lesen Sie den Rest von .
6. Client-Eingabe
Es ist an der Zeit, das Spiel spielbar zu machen! Das Steuerungsschema wird sehr einfach sein: Um die Bewegungsrichtung zu Ă€ndern, kann die Maus (am Computer) oder der Bildschirm (auf mobilen GerĂ€ten) verwendet werden. Um dies umzusetzen, registrieren wir fĂŒr Maus- und Touchereignisse.
Das erledigt src/client/input.js:
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() â das sind Event Listener, die updateDirection() (von networking.js) beim Auslösen eines Eingabeevents (z. B. beim Bewegen der Maus) aufgerufen werden. updateDirection() kommuniziert mit dem Server, der das Eingabeereignis verarbeitet und den Spielzustand entsprechend aktualisiert.
7. Zustand des Clients
Dieser Abschnitt ist der komplexeste Teil des ersten Beitrags. Machen Sie sich keine Sorgen, wenn Sie ihn beim ersten Lesen nicht verstehen! Sie können ihn sogar ĂŒberspringen und spĂ€ter wieder darauf zurĂŒckkommen.
Das letzte PuzzlestĂŒck, das fĂŒr den Abschluss des Client-Server-Codes erforderlich ist, ist state. Erinnern Sie sich an den Codeabschnitt aus dem Abschnitt "Client-Rendering"?
render.js
import { getCurrentState } from '.\/state';
function render() {
const { me, others, bullets } = getCurrentState();
\/\/ FĂŒhren Sie das Rendering durch
\/\/ ...
} getCurrentState() muss in der Lage sein, uns den aktuellen Spielzustand im Client zu jedem Zeitpunkt basierend auf den vom Server empfangenen Updates zur VerfĂŒgung zu stellen. Hier ist ein Beispiel fĂŒr ein Spielupdate, das der 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 Spielupdate enthĂ€lt fĂŒnf identische Felder:
- t: der Zeitstempel des Servers, der den Zeitpunkt der Erstellung dieses Updates bezeichnet.
- me: Informationen ĂŒber den Spieler, der dieses Update erhĂ€lt.
- others: ein Array mit Informationen ĂŒber andere Spieler, die am selben Spiel teilnehmen.
- bullets: ein Array mit Informationen ĂŒber Geschosse im Spiel.
- leaderboard: die aktuellen Daten der Bestenliste. In diesem Beitrag werden wir sie nicht berĂŒcksichtigen.
7.1 Naiver Zustand des Clients
Naive Implementierung getCurrentState() kann nur die Daten des zuletzt empfangenen Spielupdates direkt zurĂŒckgeben.
naive-state.js
let lastGameUpdate = null;
\/\/ Verarbeiten Sie ein neu empfangenes Spielupdate.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}Schön und klar! Aber wenn es so einfach wĂ€re. Einer der GrĂŒnde, warum eine solche Implementierung problematisch ist: sie beschrĂ€nkt die Bildrate des Renderings auf die Taktfrequenz des Servers.
Bildrate (Frame Rate): die Anzahl der Bilder (d.h. Aufrufe
render()) pro Sekunde, oder FPS. In Spielen strebt man normalerweise an, mindestens 60 FPS zu erreichen.
Taktfrequenz (Tick Rate): die Frequenz, mit der der Server Spielupdates an die Clients sendet. Oft liegt sie unter der Bildrate. In unserem Spiel arbeitet der Server mit einer Frequenz von 30 Ticks pro Sekunde.
Wenn wir einfach nur das letzte Update des Spiels rendern, kann die FPS im Grunde niemals 30 ĂŒberschreiten, weil wir niemals 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 somit nichts bewirken. Ein weiteres Problem bei der naiven Implementierung ist, dass sie anfĂ€llig fĂŒr Verzögerungen ist. Bei idealer Internetgeschwindigkeit erhĂ€lt der Client das Spiel-Update genau alle 33 ms (30 pro Sekunde):

Leider ist nichts perfekt. Ein realistischeres Bild wÀre folgendes:

Die naive Implementierung ist praktisch der schlechteste Fall, wenn es um Verzögerungen geht. Wenn das Spiel-Update mit einer Verzögerung von 50 ms empfangen wird, dann verzögert sich der Client um zusĂ€tzliche 50 ms, weil er weiterhin den Status des Spiels aus dem vorherigen Update rendert. Sie können sich vorstellen, wie unangenehm das fĂŒr den Spieler ist: Aufgrund willkĂŒrlicher Ruckler wird das Spiel ruckelig und instabil erscheinen.
7.2 Verbesserter Client-Zustand
Wir werden einige Verbesserungen in die naive Implementierung einbringen. ZunĂ€chst verwenden wir eine Rendering-Verzögerung von 100 ms. Das bedeutet, dass der "aktuelle" Zustand des Clients immer 100 ms hinter dem Status des Spiels auf dem Server zurĂŒckbleibt. Beispielsweise wird, wenn auf dem Server die Zeit gleich ist 150, der Zustand gerendert, in dem sich der Server zu dem Zeitpunkt befand 50:

. Das gibt uns einen Puffer von 100 ms, der es ermöglicht, unvorhersehbare Zeiten fĂŒr den Empfang von Spiel-Updates zu ĂŒberstehen:

Der Preis dafĂŒr ist eine stĂ€ndige von 100 ms. Dies ist ein geringfĂŒgiges Opfer fĂŒr einen flĂŒssigen Spielablauf â die meisten Spieler (insbesondere Gelegenheitsspieler) werden diese Verzögerung kaum bemerken. Es ist viel einfacher fĂŒr die Menschen, sich an eine konstante Verzögerung von 100 ms zu gewöhnen, als mit unvorhersehbaren Verzögerungen zu spielen.
Wir können auch eine andere Technik namens die gut funktioniert, um die wahrgenommenen Verzögerungen zu reduzieren, aber in diesem Beitrag wird sie nicht behandelt.
Eine weitere Verbesserung, die wir verwenden, ist lineare Interpolation.Durch die Rendering-Verzögerung ĂŒberholen wir in der Regel mindestens ein Update, um die aktuelle Zeit im Client. Wenn wird getCurrentState()aufgerufen, können wir zwischen den Spielupdates direkt vor und nach der aktuellen Zeit im Client:

Das löst das Problem mit der Bildrate: Jetzt können wir einzigartige Frames mit jeder gewĂŒnschten Frequenz rendern!
7.3 Implementierung eines verbesserten Client-Zustands
Beispielimplementierung in src/client/state.js nutzt sowohl die Renderverzögerung als auch die lineare Interpolation, aber das ist nur vorĂŒbergehend. Lassen Sie uns den Code in zwei Teile aufteilen. 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);
// Behalte nur ein Spielupdate vor der aktuellen Serverzeit
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Gibt den Index des Basisupdates zurĂŒck, das erste Spielupdate vor
// der aktuellen Serverzeit, oder -1, wenn 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;
} Zuerst mĂŒssen wir verstehen, was currentServerTime()macht. Wie wir zuvor gesehen haben, wird bei jedem Spielupdate ein Server-Zeitstempel eingefĂŒgt. Wir möchten die Renderverzögerung nutzen, um ein Bild mit einer Verzögerung von 100 ms hinter dem Server zu rendern, aber wir werden nie wissen, was die aktuelle Zeit auf dem Server ist, denn wir können nicht wissen, wie lange es gedauert hat, bis eines der Updates zu uns gelangt ist. Das Internet ist unberechenbar und die Geschwindigkeit kann stark schwanken!
Um dieses Problem zu umgehen, können wir eine vernĂŒnftige AnnĂ€herung verwenden: Wir tun so, als wĂ€re das erste Update sofort angekommen. Wenn das wahr wĂ€re, wĂŒssten wir die Serverzeit in diesem speziellen Moment! Wir speichern den Server-Zeitstempel in firstServerTimestamp und speichern unseren lokalen (Client-)Zeitstempel im selben Moment in gameStart.
Oh, einen Moment mal. Sollte die Zeit auf dem Server nicht gleich der Zeit im Client sein? Warum unterscheiden wir zwischen âServer-Zeitstempelâ und âClient-Zeitstempelâ? Das ist eine gute Frage! Es scheint, dass das nicht dasselbe ist. Date.now() gibt unterschiedliche Zeitstempel im Client und auf dem Server zurĂŒck und das hĂ€ngt von lokalen Faktoren dieser Maschinen ab. Nehmen Sie niemals an, dass die Zeitstempel auf allen Maschinen gleich sind.
Nun verstehen wir, was macht: currentServerTime()es gibt den Server-Zeitstempel der aktuellen Rendering-Zeit zurĂŒck. Mit anderen Worten, das ist die aktuelle Serverzeit (firstServerTimestamp < (Date.now() - gameStart)) minus die Rendering-Verzögerung (RENDER_DELAY).
. Nun lassen Sie uns verstehen, wie wir die Spielaktualisierungen verarbeiten. Bei Erhalt einer Aktualisierung vom Server wird processGameUpdate(), und wir speichern die neue Aktualisierung im Array gameUpdates. Dann entfernen wir, um den Speicherverbrauch zu ĂŒberprĂŒfen, alle alten Aktualisierungen bis zu der Basisaktualisierung, da wir sie nicht mehr benötigen.
Was ist also eine âBasisaktualisierungâ? Das ist die erste Aktualisierung, die wir finden, wĂ€hrend wir rĂŒckwĂ€rts von der aktuellen Serverzeit gehen. Erinnern Sie sich an dieses Schema?

Die Spielaktualisierung, die sich direkt links von âClient Render Timeâ befindet, ist die Basisaktualisierung.
Wozu wird die Basisaktualisierung verwendet? Warum können wir Aktualisierungen bis zur Basis abweisen? Um das zu verstehen, lassen Sie uns endlich die Implementierung betrachten 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:
base < 0bedeutet, dass es vor der aktuellen Renderingzeit keine Aktualisierungen gibt (siehe oben die Implementierung vongetBaseUpdate()). Dies kann gleich zu Beginn des Spiels aufgrund von Rendering-Verzögerungen auftreten. In diesem Fall verwenden wir die letzte erhaltene Aktualisierung.baseist die letzte Aktualisierung, die wir haben. Dies kann aufgrund von Netzwerkverzögerungen oder schlechter Internetverbindung passieren. In diesem Fall verwenden wir ebenfalls die letzte Aktualisierung, die wir haben.- Wir haben eine Aktualisierung sowohl vor als auch nach der aktuellen Renderingzeit, deshalb können wir interpolieren!
Alles, was ĂŒbrig bleibt, ist state.js die Implementierung der linearen Interpolation, die eine einfache (aber langweilige) Mathematik darstellt. Wenn Sie sie selbst lernen möchten, öffnen Sie state.js auf .
Teil 2. Backend-Server
In diesem Teil werden wir den Node.js-Backend-Server betrachten, der unser .
1. Einstiegspunkt des Servers
Zur Verwaltung des Webservers verwenden wir ein beliebtes Web-Framework fĂŒr Node.js namens . Die Konfiguration ĂŒbernimmt unsere Einstiegspunktdatei des Servers 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');
// Ein Express-Server einrichten
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Webpack fĂŒr die Entwicklung einrichten
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Statische Bereitstellung des dist\/ Ordners in der Produktion
app.use(express.static('dist'));
}
// Auf Port hören
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server hört auf Port ${port}`);Erinnern Sie sich, dass wir im ersten Teil ĂŒber Webpack gesprochen haben? Genau hier werden wir unsere Webpack-Konfigurationen verwenden. Wir werden sie auf zwei Arten anwenden:
- Verwenden zur automatischen Neukompilierung unserer Entwicklungs-Pakete oder
- Statisch den Ordner
dist/, in den Webpack unsere Dateien nach dem Produktions-Build schreiben wird.
Eine weitere wichtige Aufgabe server.js besteht darin, den Server , der einfach mit dem Express-Server verbunden wird, einzurichten:
server.js, Teil 2
const socketio = require('socket.io');
const Constants = require('..\/shared\/constants');
// Express einrichten
// ...
const server = app.listen(port);
console.log(`Server hört auf Port ${port}`);
// socket.io einrichten
const io = socketio(server);
// Auf socket.io-Verbindungen hören
io.on('connection', socket => {
console.log('Spieler verbunden!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Nach dem erfolgreichen Herstellen der socket.io-Verbindung mit dem Server richten wir Ereignis-Handler fĂŒr den neuen Socket ein. Die Ereignis-Handler verarbeiten die von den Clients empfangenen Nachrichten, indem sie an das Singleton-Objekt delegieren. game:
server.js, Teil 3
const Game = require('.\/game');
// ...
// Das Spiel einrichten
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 im .io-Genre, daher benötigen wir nur eine Instanz Game (âGameâ) â alle Spieler spielen auf einer Arena! Im nĂ€chsten Abschnitt werden wir uns ansehen, wie diese Klasse funktioniert. Game.
2. Server Game
Klasse Game enthÀlt die wichtigste Logik auf Serverseite. Sie hat zwei Hauptaufgaben: Verwaltung der Spieler und Simulation des Spiels.
Lassen Sie uns mit der ersten Aufgabe beginnen â der Verwaltung der Spieler.
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;
// Generiere eine Position, um diesen Spieler zu starten.
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 werden wir die Spieler anhand des Feldes id ihrer socket.io-Verbindung identifizieren (falls Sie verwirrt sind, gehen Sie zurĂŒck zu server.js). Socket.io weist jedem Socket eine eindeutige idID zu, weshalb wir uns darĂŒber keine Sorgen machen mĂŒssen. Ich werde es Spieler-ID.
Merken Sie sich das, und lassen Sie uns die Instanzvariablen in der Klasse Game:
socketsâ das ist ein Objekt, das die ID des Spielers mit dem Socket verknĂŒpft, der mit dem Spieler verbunden ist. Es ermöglicht uns, in konstanter Zeit auf die Sockets anhand ihrer Spieler-IDs zuzugreifen.playersâ das ist ein Objekt, das die ID des Spielers mit dem code>Player-Objekt verknĂŒpft.
bullets â das ist ein Array von Objekten Bullet, das keine bestimmte Reihenfolge hat.
lastUpdateTime â das ist der Zeitstempel des letzten Updates des Spiels. Bald werden wir sehen, wie es verwendet wird.
shouldSendUpdate â das ist eine Hilfsvariable. Deren Verwendung werden wir ebenfalls bald sehen.
Methoden addPlayer(), removePlayer() und handleInput() es bedarf keiner ErklĂ€rung, sie werden in server.jsverwendet. Wenn Sie Ihr GedĂ€chtnis auffrischen mĂŒssen, gehen Sie etwas weiter nach oben.
Die letzte Zeile constructor() startet den Aktualisierungszyklus des Spiels (mit 60 Aktualisierungen pro Sekunde):
game.js, Teil 2
const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ Berechne die vergangene Zeit
const now = Date.now();
const dt = (now - this.lastUpdateTime) \/ 1000;
this.lastUpdateTime = now;
\/\/ Aktualisiere jede Kugel
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
\/\/ Zerstöre diese Kugel
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
\/\/ Aktualisiere jeden Spieler
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
\/\/ Wende Kollisionen an, gebe den Spielern Punkte fĂŒr Treffer an Kugeln
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),
);
\/\/ ĂberprĂŒfe, ob Spieler tot sind
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
if (player.hp {
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;
}
}
\/\/ ...
} Methode update() enthÀlt wahrscheinlich den wichtigsten Teil der Logik auf der Serverseite. Lassen Sie uns der Reihenfolge nach auflisten, was er tut:
- Berechnet, wie viel Zeit
dtseit dem letztenupdate(). - Aktualisiert jede Kugel und zerstört sie bei Bedarf. Die Umsetzung dieser FunktionalitÀt werden wir spÀter sehen. VorlÀufig reicht es zu wissen, dass
bullet.update()gibt zurĂŒcktrue, wenn die Kugel zerstört werden soll (wenn sie den Rand der Arena ĂŒberschreitet). - Aktualisiert jeden Spieler und erstellt bei Bedarf eine Kugel. Diese Umsetzung werden wir ebenfalls spĂ€ter sehen â
player.update()kann ein Objekt zurĂŒckgebenBullet. - ĂberprĂŒft Kollisionen zwischen Kugeln und Spielern mithilfe von
applyCollisions(), welches ein Array von Kugeln zurĂŒckgibt, die die Spieler getroffen haben. FĂŒr jede zurĂŒckgegebene Kugel erhöhen wir die Punkte des Spielers, der sie abgefeuert hat (mithilfe vonplayer.onDealtDamage()), und entfernen dann die Kugel aus dem Arraybullets. - Benachrichtigt und zerstört alle getöteten Spieler.
- Sendet allen Spielern ein Update des Spiels jedes zweite Mal beim Aufruf
update(). Das hilft uns, die oben erwĂ€hnte Hilfsvariable zu verfolgenshouldSendUpdate. Daupdate()Es wird 60 Mal pro Sekunde aufgerufen, wir senden Spiel-Updates 30 Mal pro Sekunde. Somit Taktfrequenz des Servers betrĂ€gt 30 Takte pro Sekunde (wir haben in Teil eins ĂŒber die Taktfrequenz gesprochen).
Warum sollten Spiel-Updates nur alle 2 Sekunden gesendet werden? ? Um die Bandbreite zu sparen. 30 Spiel-Updates pro Sekunde sind sehr viel!
Warum ruft man dann nicht einfach
update()30 Mal pro Sekunde auf? Um die Simulation des Spiels zu verbessern. Je hĂ€ufiger es aufgerufen wird,update(), desto genauer wird die Spielfsimulation. Aber man sollte sich nicht zu sehr in der Anzahl der Aufrufe verlieren,update(), denn dies ist rechenintensiv â 60 pro Sekunde sind mehr als ausreichend.
Der verbleibende Teil der Klasse Game besteht aus Hilfsmethoden, die in update():
game.js, Teil 3, verwendet werden.
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) b.distanceTo(player) p.serializeForUpdate()),
bullets: nearbyBullets.map(b => b.serializeForUpdate()),
leaderboard,
};
}
} getLeaderboard() ist ziemlich einfach â es sortiert die Spieler nach der Anzahl der Punkte, nimmt die fĂŒnf besten und gibt fĂŒr jeden den Benutzernamen und den Punktestand zurĂŒck.
createUpdate() wird in update() verwendet, um Spiel-Updates zu erstellen, die an die Spieler weitergegeben werden. Seine Hauptaufgabe besteht darin, die Methoden serializeForUpdate(), die fĂŒr die Klassen Player und Bullet, implementiert sind, aufzurufen. Beachten Sie, dass es jedem Spieler nur Informationen ĂŒber die nĂ€chsten Spieler und Geschosse ĂŒbermittelt â es ist nicht notwendig, Informationen ĂŒber Spielobjekte, die weit weg sind, zu ĂŒbertragen!
3. Spielobjekte auf dem Server
In unserem Spiel sind Geschosse und Spieler tatsĂ€chlich sehr Ă€hnlich: es sind abstrakte, runde, bewegliche Spielobjekte. Um diese Ăhnlichkeit zwischen Spielern und Geschossen zu nutzen, lassen Sie uns mit der Implementierung einer Basisklasse beginnen 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 Ausgangspunkt fĂŒr Erweiterungen sein. Lassen Sie uns 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;
}
\/\/ Gibt true zurĂŒck, wenn die Kugel zerstört werden soll
update(dt) {
super.update(dt);
return this.x Constants.MAP_SIZE || this.y Constants.MAP_SIZE;
}
} Implementierung Bullet sehr kurz! Wir haben hinzugefĂŒgt Object nur die folgenden Erweiterungen:
- Verwendung des Pakets zur zufÀlligen Generierung
idvon Projektile. - HinzufĂŒgen des Feldes
parentID, um den Spieler zu verfolgen, der dieses Projektil erstellt hat. - HinzufĂŒgen eines RĂŒckgabewerts in
update(), der gleichtrue, ist, wenn das Projektile auĂerhalb der Arena ist (denken Sie daran, wir haben in dem vorherigen Abschnitt darĂŒber gesprochen?).
Lassen Sie uns zu 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;
}
\/\/ Gibt eine neu erstellte Kugel oder null zurĂŒck.
update(dt) {
super.update(dt);
\/\/ Aktualisiere den Punktestand
this.score += dt * Constants.SCORE_PER_SECOND;
\/\/ Stelle sicher, dass der Spieler im Rahmen bleibt
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
\/\/ Feuere eine Kugel, wenn nötig
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 komplizierter als Projektile, daher sollte in dieser Klasse auch noch ein paar weitere Felder gespeichert werden. Seine Methode update() erledigt mehr Arbeit, insbesondere gibt sie das gerade neu erstellte Projektile zurĂŒck, wenn kein fireCooldown ĂŒbrig ist (denken Sie daran, wir haben in dem vorherigen Abschnitt darĂŒber gesprochen?). AuĂerdem erweitert sie die Methode serializeForUpdate(), da wir zusĂ€tzliche Felder fĂŒr den Spieler in die Spielaktualisierung einbeziehen mĂŒssen.
Ein Basisklasse zu haben Object ist ein wichtiger Schritt, um Wiederholungen im Code zu vermeiden. Zum Beispiel mĂŒsste ohne eine Klasse Object jedes Spielobjekt eine identische Implementierung haben distanceTo(), und die Synchronisierung des Kopierens und EinfĂŒgens all dieser Implementierungen in mehreren Dateien wĂ€re ein Albtraum. Das wird besonders wichtig fĂŒr groĂe Projekte, wenn die Anzahl der Erweiterung Object klassen wĂ€chst.
4. Kollisionserkennung
Das Einzige, was uns bleibt, ist zu erkennen, wann die Geschosse die Spieler treffen! Erinnern Sie sich an diesen Codeabschnitt aus der Methode update() in der Klasse Game:
game.js
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ ...
\/\/ Kollisionen anwenden, Spielern Punkte fĂŒr das Treffen von Geschossen geben
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 die Methode implementieren applyCollisions(), die alle Geschosse zurĂŒckgibt, die die Spieler getroffen haben. Zum GlĂŒck ist das nicht so schwierig, weil
- Alle kollidierenden Objekte sind Kreise, und das ist die einfachste Form der Kollisionserkennung.
- Wir haben bereits die Methode
distanceTo(), die wir im vorherigen Abschnitt in 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 ist als die Summe ihrer Radien.Hier ist der Fall, wenn der Abstand zwischen den Zentren der beiden Kreise genau der Summe ihrer Radien entspricht:

Hier mĂŒssen noch ein paar Aspekte genau betrachtet werden:
- Ein Geschoss darf nicht den Spieler treffen, der es erschaffen hat. Das erreichen wir, indem wir vergleichen
bullet.parentIDcplayer.id. - Ein Geschoss sollte im Grenzfall einer gleichzeitigen Kollision mit mehreren Spielern nur einmal treffen. Diese Aufgabe lösen wir mit dem Operator
break: Sobald wir einen Spieler gefunden haben, der mit dem Geschoss kollidiert ist, brechen wir die Suche ab und wechseln zum nÀchsten Geschoss.
Ende
Das ist alles! Wir haben alles besprochen, was man wissen muss, um ein .io-Webspiel zu erstellen. Was kommt als NĂ€chstes? Bauen Sie Ihr eigenes .io-Spiel!
Der gesamte Beispielcode ist Open Source und auf .
Quelle: habr.com
