Erstellung eines Mehrspieler-Webspiels im .io-Genre

Erstellung eines Mehrspieler-Webspiels im .io-Genre
Erschienen im Jahr 2015 Agar.io wurde zum Vorreiter eines neuen Genres von .io Spielen, dessen Beliebtheit seitdem stark gewachsen ist. Ich habe das Wachstum der Beliebtheit von .io Spielen selbst erfahren: In den letzten drei Jahren habe ich zwei Spiele dieses Genres erstellt und verkauft..

Falls Sie noch nie von solchen Spielen gehört haben: Es handelt sich um kostenlose Multiplayer-Webspiele, an denen man leicht teilnehmen kann (keine Registrierung erforderlich). Normalerweise treten viele gegnerische Spieler auf einer Arena gegeneinander an. Weitere bekannte Spiele des .io Genres sind: Slither.io und Diep.io.

In diesem Beitrag werden wir uns ansehen, wie man ein .io Spiel von Grund auferstellt. Dafür genügt das Wissen über Javascript: Sie sollten Dinge wie die Syntax ES6, das Schlüsselwort this und und Promises verstehen.Selbst wenn Sie Javascript nicht perfekt beherrschen, werden Sie dennoch die meisten Teile des Beitrags verstehen können.

Beispiel eines .io Spiels

Zur Unterstützung beim Lernen werden wir auf ein Beispiel eines .io Spielsverweisen. Versuchen Sie, es zu spielen!

Erstellung eines Mehrspieler-Webspiels im .io-Genre
Das Spiel ist recht einfach: Sie steuern ein Raumschiff auf einer Arena, in der sich andere Spieler befinden. Ihr Raumschiff schießt automatisch Projektile ab und Sie versuchen, andere Spieler zu treffen, während Sie gleichzeitig ihren Projektilen ausweichen.

1. Kurze Übersicht/Projektstruktur

Empfehlen Quellcode herunterladen des Spielbeispiels, damit Sie mir folgen können.

Im Beispiel werden folgende Technologien verwendet:

  • Express — das beliebteste Web-Framework für Node.js, das den Webserver des Spiels steuert.
  • socket.io — eine Websocket-Bibliothek für den Datenverkehr zwischen Browser und Server.
  • Webpack — ein Modulpaketmanager. Mehr darüber, warum man Webpack verwenden sollte, finden Sie hier.

So sieht die Verzeichnisstruktur des Projekts aus:

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

public/

Alles im Ordner public/ wird statisch vom Server bereitgestellt. In public/assets/ sind die Bilder enthalten, die unser Projekt verwendet.

src/

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

2. Builds/Projekteinstellungen

Wie bereits erwähnt, nutzen wir für den Build des Projekts den Modulpaketmanager Webpack. 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 Zeilen hier sind:

  • src/client/index.js — dies ist der Einstiegspunkt für den JavaScript-Client (JS). Webpack beginnt hier und sucht rekursiv nach anderen importierten Dateien.
  • Die Ausgabe-JS unserer Webpack-Bundle wird sich im Verzeichnis dist/. Ich werde diese Datei unser JS-Paket.
  • Wir nutzen Babel.nennen, insbesondere die Konfiguration @babel/preset-env für die Transpilation unseres JS-Codes für ältere Browser.
  • Wir verwenden ein Plugin, um alle CSS-Dateien zu extrahieren, auf die von den JS-Dateien verwiesen wird, und sie an einem Ort zusammenzuführen. Ich werde es unser CSS-Paket.

nennen. Vielleicht ist Ihnen aufgefallen, dass die Dateinamen der Pakete '[name].[contenthash].ext'. Sie enthalten Platzhalter für Dateinamen Webpack: [name] wird durch den Namen des Einstiegspunkts ersetzt (in unserem Fall ist es game), und [contenthash] wird durch den Hash des Inhalts der Datei ersetzt. Wir tun dies, um das Projekt für das Hashing zu optimieren — so können wir den Browser anweisen, unsere JS-Pakete unendlich zu cachen, denn wenn sich das Paket ändert, ändert sich auch sein Dateiname (es ändert sich contenthash). Das Ergebnis ist ein Dateiname wie game.dbeee76e91a97d0c7207.js.

Die Datei webpack.common.js — dies ist die grundlegende Konfigurationsdatei, die wir in den Entwicklungs- und Produktionskonfigurationen importieren. Hier ist zum Beispiel die Entwicklungs-Konfiguration:

webpack.dev.js

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
});

Um effizient zu arbeiten, verwenden wir in der Entwicklungsphase webpack.dev.js, und wechselt zu webpack.prod.js, um die Paketgrößen beim Deployment in die Produktion zu optimieren.

Lokale Einrichtung

Ich empfehle, das Projekt auf der lokalen Maschine einzurichten, damit Sie den Schritten in diesem Beitrag folgen können. Die Einrichtung ist einfach: Zuerst müssen Knoten und NPMinstalliert sein.

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

Und Sie sind bereit zur Arbeit! Um den Entwicklungsserver zu starten, müssen Sie nur

$ npm run develop

eingeben und im Webbrowser auf localhost:3000 zugreifen können.. Der Entwicklungsserver wird JS- und CSS-Pakete automatisch neu kompilieren, während Sie den Code ändern – aktualisieren Sie einfach die Seite, um alle Änderungen zu sehen!

3. Client-Endpunkte

Lassen Sie uns mit dem eigentlichen Spielcode beginnen. Zuerst benötigen wir eine Seite index.html, die beim Besuch der Website zuerst geladen wird. Unsere Seite wird recht 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="username-input" placeholder="Benutzername" />
    <button id="play-button">SPIELEN</button>
  </div>
</body>
</html>

Dieses Codebeispiel wurde leicht vereinfacht, um es verständlicher zu machen; das gleiche werde ich mit vielen anderen Beispielen im Beitrag tun. Den vollständigen Code finden Sie immer auf Github.

Wir haben:

  • HTML5 Canvas-Element (<canvas>), das wir verwenden werden, um das Spiel zu rendern.
  • <link> zum Hinzufügen unseres CSS-Pakets.
  • <script> zum Hinzufügen unseres Javascript-Pakets.
  • Hauptmenü mit Benutzernamen <input> und der Schaltfläche „SPIELEN“ (

Nach dem Laden der Startseite wird der Javascript-Code im Browser 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);
  };
});

Es mag kompliziert erscheinen, aber tatsächlich geschieht hier nicht viel:

  1. Import mehrerer anderer JS-Dateien.
  2. Import von CSS (damit Webpack weiß, dass es in unser CSS-Paket aufgenommen werden muss).
  3. Start connect() zum Herstellen einer Verbindung zum Server und zum Starten. downloadAssets() zum Herunterladen der Bilder, die für das Rendern des Spiels erforderlich sind.
  4. Nach Abschluss von Schritt 3 wird das Hauptmenü angezeigt (playMenu).
  5. Einrichten des Click-Handlers für die Schaltfläche 'PLAY'. Bei Klick auf die Schaltfläche initialisiert der Code das Spiel und informiert den Server, dass wir bereit sind zu spielen.

Das Haupt-„Kernstück“ unserer klient-server-logik befindet sich in den Dateien, die durch die Datei importiert wurden. index.js. Jetzt betrachten wir sie alle der Reihe nach.

4. Kundendatenübertragung

In diesem Spiel verwenden wir zur Kommunikation mit dem Server eine gut bekannte Bibliothek socket.io. Socket.io hat integrierte Unterstützung für WebSockets, die sich gut für 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(() => {
    // Registeriere Rückrufe
    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 wurde zur besseren Verständlichkeit auch etwas gekürzt.

In dieser Datei erfolgen drei Hauptaktionen:

  • Wir versuchen, eine Verbindung zum Server herzustellen. connectedPromise wird nur erfüllt, wenn wir die Verbindung hergestellt haben.
  • Wenn die Verbindung erfolgreich hergestellt ist, registrieren wir die Callback-Funktionen (processGameUpdate() und onGameOver()) für die Nachrichten, die wir vom Server empfangen können.
  • Wir exportieren play() und updateDirection(), 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 benötigten Bilder (Ressourcen) herunterladen. Lassen Sie 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];

Die Verwaltung von Ressourcen ist nicht so kompliziert! Der Hauptzweck besteht darin, ein Objekt assetszu speichern, das den Dateinamen als Schlüssel mit dem Wert des Objekts verknüpft. BildWenn die Ressource heruntergeladen wird, speichern wir sie im Objekt assets für einen schnellen Zugriff in der Zukunft. Sobald das Herunterladen jeder einzelnen Ressource (d.h. die Ressourcen wurden geladen) erlaubt ist, erlauben wir alle Ressourcen), wir erlauben downloadPromise.

Nachdem die Ressourcen heruntergeladen wurden, kann mit dem Rendering begonnen werden. Wie bereits erwähnt, verwenden wir dazu HTML5 Canvas (<canvas>). Unser Spiel ist ziemlich einfach, daher müssen wir nur das Folgende rendern:

  1. den Hintergrund
  2. das Spieler-Schiff
  3. andere Spieler, die im Spiel sind
  4. Geschosse

Hier sind die wichtigen Teile 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;

// Holen Sie sich den Grafik-Kontext des Canvas
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Machen Sie das Canvas im Vollbildmodus
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

function render() {
  const { me, others, bullets } = getCurrentState();
  if (!me) {
    return;
  }

  // Hintergrund zeichnen
  renderBackground(me.x, me.y);

  // Alle Geschosse zeichnen
  bullets.forEach(renderBullet.bind(null, me));

  // Alle Spieler zeichnen
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Hilfsfunktionen hier ausgelassen

let renderInterval = null;
export function startRendering() {
  renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
  clearInterval(renderInterval);
}

Dieser Code wurde ebenfalls zur Verdeutlichung gekürzt.

render() ist die Hauptfunktion dieser Datei. startRendering() und stopRendering() steuern die Aktivierung des Rendering-Zyklus mit einer Frequenz von 60 FPS.

Die spezifischen Implementierungen einzelner Hilfsfunktionen für das Rendering (zum Beispiel 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 wir zuvor in asset.js!

Wenn Sie daran interessiert sind, weitere Hilfsfunktionen für das Rendering zu studieren, lesen Sie den Rest von src/client/render.js.

6. Benutzereingabe

Es ist an der Zeit, das Spiel spielbar zu machen! Das Steuerungsschema wird sehr einfach sein: Um die Bewegungsrichtung zu ändern, können Sie die Maus (am Computer) oder den Bildschirm berühren (auf mobilen Geräten) verwenden. Um dies umzusetzen, registrieren wir Event-Listener für die Mouse- und Touch-Ereignisse.
Damit beschäftigt sich 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() — sind Event-Listener, die bei updateDirection() (aus networking.js) Eingabeereignisse (zum Beispiel beim Bewegen der Maus) ausgelöst werden. updateDirection() verantwortlich für die Kommunikation mit dem Server, der das Eingabeereignis verarbeitet und den Spielstatus entsprechend aktualisiert.

7. Client-Zustand

Dieser Abschnitt ist der schwierigste Teil des ersten Teils des Beitrags. Seien Sie nicht enttäuscht, wenn Sie ihn beim ersten Lesen nicht verstehen! Sie können ihn sogar überspringen und später zurückkommen.

Das letzte Puzzlestück, das benötigt wird, um den Client-Server-Code abzuschließen — ist state. Erinnern Sie sich an den Codeausschnitt aus dem Abschnitt "Rendering des Clients"?

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 Spielstatus im Client bereitzustellen zu jedem Zeitpunkt basierend auf den Updates, die vom Server empfangen werden. Hier ist ein Beispiel für ein Spiel-Update, 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": "Spieler",
      "score": 3
    }
  ]
}

Jedes Spielupdate enthält fünf identische Felder:

  • t: Zeitstempel des Servers, der den Zeitpunkt der Erstellung dieses Updates angibt.
  • me: Informationen über den Spieler, der dieses Update empfängt.
  • others: ein Array von Informationen über andere Spieler, die am gleichen Spiel teilnehmen.
  • bullets: ein Array von Informationen über die Geschosse im Spiel.
  • leaderboard: aktuelle Daten der Bestenliste. In diesem Beitrag werden wir sie nicht berücksichtigen.

7.1 Naiver Zustand des Clients

Naive Implementierung getCurrentState() kann nur direkt die Daten des zuletzt empfangenen Spielupdates zurückgeben.

naive-state.js

let lastGameUpdate = null;

// Verarbeitung eines neu empfangenen Spielupdates.
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 der Renderung auf die Taktfrequenz des Servers.

Bildrate (Frame Rate): Anzahl der Bilder (d.h. Aufrufe render()) pro Sekunde, oder FPS. In Spielen strebt man normalerweise mindestens 60 FPS an.

Taktfrequenz (Tick Rate): die Frequenz, mit der der Server den Clients Spielupdates sendet. Oft liegt sie unter der Bildrate. In unserem Spiel arbeitet der Server mit 30 Takten pro Sekunde.

Wenn wir nur das letzte Spielupdate rendern, kann die FPS im Wesentlichen nie über 30 steigen, weil wir vom Server niemals mehr als 30 Updates pro Sekunde erhalten. Selbst wenn wir render() 60 Mal pro Sekunde aufrufen, würde die Hälfte dieser Aufrufe einfach das Gleiche neu zeichnen, also im Grunde nichts tun. Ein weiteres Problem einer naiven Implementierung besteht darin, dass sie anfällig für Verzögerungen ist.. Mit optimaler Internetgeschwindigkeit erhält der Klient alle 33 ms (30 pro Sekunde) ein Spiel-Update.

Erstellung eines Mehrspieler-Webspiels im .io-Genre
Leider ist nichts perfekt. Ein realistischeres Bild wäre folgendes:
Erstellung eines Mehrspieler-Webspiels im .io-Genre
Eine naive Implementierung stellt praktisch den schlimmsten Fall in Bezug auf Verzögerungen dar. Wenn ein Spiel-Update mit einer Verzögerung von 50 ms empfangen wird, wird der Klient um 50 ms behindert, da er weiterhin den Zustand des Spiels aus dem vorherigen Update rendert. Sie können sich vorstellen, wie unangenehm das für den Spieler ist: Aufgrund zufälliger Verzögerungen wirkt das Spiel ruckartig und instabil.

7.2 Verbesserter Zustand des Klienten

Wir werden einige Verbesserungen an der naiven Implementierung vornehmen. Erstens verwenden wir eine Renderverzögerung von 100 ms. Das bedeutet, dass der "aktuelle" Zustand des Klienten immer um 100 ms hinter dem Zustand des Spiels auf dem Server liegt. Wenn das Zeit auf dem Server 150beträgt, wird der Klient den Zustand rendern, den der Server zur Zeit hatte, 50:

Erstellung eines Mehrspieler-Webspiels im .io-Genre
Das gibt uns einen Puffer von 100 ms, der es uns ermöglicht, unvorhersehbare Empfangszeiten für Spiel-Updates zu überstehen:

Erstellung eines Mehrspieler-Webspiels im .io-Genre
Der Preis dafür ist eine permanente Eingabeverzögerung (input lag) bei 100 ms. Das ist ein kleiner Preis für ein reibungsloses Gameplay – die meisten Spieler (insbesondere Gelegenheits-Spieler) werden diese Verzögerung kaum bemerken. Menschen gewöhnen sich viel leichter an eine konstante Verzögerung von 100 ms, als an unvorhersehbare Latenzen zu spielen.

Wir können auch eine andere Technik verwenden, die „Client-seitige Vorhersage“, die gut darin ist, die wahrgenommenen Latenzen zu reduzieren, aber in diesem Beitrag nicht behandelt wird.

Eine weitere Verbesserung, die wir verwenden, ist die lineare Interpolation. Aufgrund der Renderverzögerung überholen wir normalerweise um mindestens ein Update die aktuelle Zeit im Client. Wenn getCurrentState()aufgerufen wird, können wir lineare Interpolation zwischen den Spiel-Updates direkt vor und nach der aktuellen Zeit im Client durchführen:

Erstellung eines Mehrspieler-Webspiels im .io-Genre
Das löst das Problem mit der Bildrate: Jetzt können wir einzigartige Frames mit jeder gewünschten Rate rendern!

7.3 Implementierung eines verbesserten Client-Zustands

Ein Beispiel für die Implementierung in src/client/state.js nutzt sowohl Renderverzögerung als auch lineare Interpolation, aber das ist nicht von Dauer. 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 nicht verfügbar.
function getBaseUpdate() {
  const serverTime = currentServerTime();
  for (let i = gameUpdates.length - 1; i >= 0; i--) {
    if (gameUpdates[i].t <= serverTime) {
      return i;
    }
  }
  return -1;
}

Zunächst müssen wir klären, was currentServerTime()macht. Wie wir zuvor gesehen haben, enthält jedes Spielupdate einen Zeitstempel vom Server. Wir möchten eine Renderverzögerung von 100 ms verwenden, um das Bild mit einer Verweichung von dem Server zu rendern, jedoch werden wir niemals die aktuelle Zeit auf dem Serverkennen, da wir nicht wissen können, wie lange eines der Updates zu uns gebraucht hat. Das Internet ist unvorhersehbar und seine Geschwindigkeit kann stark variieren!

Eine Lösung für dieses Problem könnte eine vernünftige Annäherung sein: Wir nehmen an, dass das erste Update sofort eingetroffen ist.. Wenn das so wäre, wüssten wir den Zeitpunkt des Servers in diesem speziellen Moment! Wir speichern den Zeitstempel des Servers in firstServerTimestamp und speichern unseren lokalen (Client-)Zeitstempel im gleichen Moment in gameStart.

Oh, halt! Sollte die Serverzeit nicht gleich der Clientzeit sein? Warum unterscheiden wir zwischen „Server-Zeitstempel“ und „Client-Zeitstempel“? Das ist eine gute Frage! Es ist nicht dasselbe. Date.now() gibt unterschiedliche Zeitstempel im Client und auf dem Server zurück, und das hängt von den lokalen Faktoren dieser Maschinen ab. Nehmen Sie niemals an, dass die Zeitstempel auf allen Maschinen identisch sind.

Jetzt verstehen wir, was currentServerTime()macht: Es gibt den Zeitstempel des Servers zur aktuellen Renderzeit zurück. Mit anderen Worten, das ist die aktuelle Serverzeit (firstServerTimestamp <+ (Date.now() - gameStart)) minus die Renderverzögerung (RENDER_DELAY).

). Lassen Sie uns nun ansehen, wie wir die Spielupdates verarbeiten. Wenn ein Update vom Server empfangen wird, wird processGameUpdate()aufgerufen, und wir speichern das neue Update im Array gameUpdates. Um den Speicherverbrauch zu überwachen, entfernen wir dann alle alten Updates bis zum Basisupdate., weil wir sie nicht mehr brauchen.

Was ist ein „Basis-Update“? Es ist das erste Update, das wir finden, wenn wir rückwärts von der aktuellen Serverzeit gehen.. Erinnern Sie sich an dieses Schema?

Erstellung eines Mehrspieler-Webspiels im .io-Genre
Das Update der Spiele ist direkt links von „Client Render Time“ und stellt das Basis-Update dar.

Wofür wird das Basis-Update verwendet? Warum können wir Updates bis zum Basis-Update verwerfen? Um dies zu klären, lassen Sie uns endlich die Implementierung 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 < 0 bedeutet, dass es vor der aktuellen Renderzeit keine Updates gibt (siehe oben die Implementierung von getBaseUpdate()). Dies kann zu Beginn des Spiels aufgrund von Renderverzögerungen auftreten. In diesem Fall verwenden wir das zuletzt erhaltene Update.
  2. basis — das ist das letzte Update, das wir haben. Dies kann aufgrund von Netzwerklatenz oder schlechter Internetverbindung passieren. In diesem Fall verwenden wir ebenfalls das letzte Update, das wir haben.
  3. Wir haben ein Update sowohl vor als auch nach der aktuellen Renderzeit, sodass wir interpolieren!

können. Alles, was verbleibt, ist state.js — dies ist die Implementierung der linearen Interpolation, die einfache (aber langweilige) Mathematik darstellt. Wenn Sie sie selbst erkunden möchten, öffnen Sie state.js findet man Github.

Teil 2. Backend-Server

In diesem Teil betrachten wir das Backend von Node.js, das unser Beispielspiel .io steuert..

1. Einstiegspunkt des Servers

Zur Verwaltung unseres Webservers verwenden wir das beliebte Web-Framework für Node.js namens Express. Die Konfiguration wird von unserer Einstiegspunkt-Datei des Servers übernommen 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');

// Richte einen Express-Server ein
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Richte Webpack für die Entwicklung ein
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Statische Bereitstellung des dist/-Ordners in der Produktion
  app.use(express.static('dist'));
}

// Höre auf dem Port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server hört auf Port ${port}`);

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

  • Verwenden webpack-dev-middleware zur automatischen Neusammlung unserer Entwicklungspakete, oder
  • statisch den Ordner übergeben dist/, in die Webpack unsere Dateien nach dem Produktions-Build schreiben wird.

Eine weitere wichtige Aufgabe server.js besteht darin, den Server socket.ioeinzurichten, der einfach mit dem Express-Server verbunden wird:

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 erfolgreicher Verbindung von socket.io mit dem Server richten wir die Ereignis-Handler für den neuen Socket ein. Die Ereignis-Handler verarbeiten die von den Clients empfangenen Nachrichten, indem sie an das Singleton-Objekt delegiert werden. 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 .io-Spiel, daher benötigen wir nur eine Instanz von Game („Game“) – alle Spieler spielen auf einer Arena! Im nächsten Abschnitt werden wir uns anschauen, wie diese Klasse funktioniert. Game.

2. Game-Server

Klasse Game enthält die wichtigste Logik auf der Serverseite. Es hat zwei Hauptaufgaben: Spielermanagement und Spielsimulation.

Lassen Sie uns mit der ersten Aufgabe beginnen – dem Spielermanagement.

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 identifizieren wir die Spieler anhand des Feldes id ihres socket.io-Sockets (wenn Sie verwirrt sind, werfen Sie einen Blick zurück auf server.js). Socket.io weist jedem Socket eine eindeutige id, daher müssen wir uns darüber keine Gedanken machen. Ich werde es Spieler-ID.

Wenn Sie sich daran erinnern, lassen Sie uns die Instanzvariablen in der Klasse Game:

  • sockets — ist ein Objekt, das die ID eines Spielers mit dem Socket verbindet, der mit diesem Spieler verknüpft ist. Es ermöglicht uns, in konstanter Zeit über die Spieler-IDs auf die Sockets zuzugreifen.
  • Spieler — ist ein Objekt, das die Spieler-ID mit dem Objekt code>Player verknüpft.

bullets — ist ein Array von Objekten. Bullet, das keine bestimmte Reihenfolge hat.
lastUpdateTime — ist ein Zeitstempel des letzten Spielupdates. Bald werden wir sehen, wie er verwendet wird.
shouldSendUpdate — ist eine Hilfsvariable. Auch ihre Verwendung werden wir bald sehen.
Methoden addPlayer(), removePlayer() und handleInput() müssen nicht erklärt werden, sie werden verwendet in server.js. Wenn Sie Ihr Gedächtnis auffrischen müssen, scrollen Sie ein wenig nach oben.

Die letzte Zeile constructor() startet den Aktualisierungszyklus des Spiels (mit 60 Updates pro Sekunde):

game.js, Teil 2

const Constants = require('../shared/constants');
const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // Berechnen Sie die verstrichene Zeit
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Aktualisieren Sie jede Kugel
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        // Zerstören Sie diese Kugel
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    // Aktualisieren Sie jeden Spieler
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    // Wenden Sie Kollisionen an, geben Sie den Spielern Punkte für das Treffen von 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üfen Sie, 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 vermutlich den wichtigsten Teil der Logik auf der Serverseite. Lassen Sie uns der Reihe nach alles auflisten, was er tut:

  1. Berechnet, wie viel Zeit dt seit dem letzten update().
  2. Aktualisiert jedes Geschoss und zerstört es bei Bedarf. Die Umsetzung dieser Funktion werden wir später sehen. Bisher genügt es zu wissen, dass bullet.update() gibt er true, wenn das Geschoss zerstört werden soll (es die Grenzen der Arena überschritten hat).
  3. Aktualisiert jeden Spieler und erstellt bei Bedarf ein Geschoss. Diese Umsetzung werden wir ebenfalls später sehen — player.update() kann ein Objekt zurückgeben Bullet.
  4. Überprüft Kollisionen zwischen Geschossen und Spielern mit Hilfe von applyCollisions(), die ein Array von Geschossen zurückgibt, die die Spieler getroffen haben. Für jedes zurückgegebene Geschoss erhöhen wir die Punkte des Spielers, der es abgefeuert hat (mit Hilfe von player.onDealtDamage()), und entfernen dann das Geschoss aus dem Array. bullets.
  5. Benachrichtigt und zerstört alle getöteten Spieler.
  6. Sendet allen Spielern jede Sekunde ein Update des Spiels jede zweite Mal bei Aufruf update(). Dies hilft uns, die oben erwähnte Hilfsvariable zu verfolgen. shouldSendUpdate. Da update() 60 Mal/s aufgerufen wird, senden wir 30 Updates pro Sekunde. Damit die Taktfrequenz Die Serverfrequenz beträgt 30 Takte pro Sekunde (wir haben in Teil eins über die Taktrate gesprochen).

Warum sollten Updates für das Spiel nur jedes zweite Mal gesendet werden? ? Zum Sparen von Bandbreite. 30 Spiel-Updates pro Sekunde sind sehr viel!

Warum nicht einfach 30 Mal pro Sekunde aufrufen? update() Für eine bessere Spielsimulation. Je häufiger ein Aufruf erfolgt, desto genauer ist die Spielsimulation. Aber man sollte es nicht übertreiben mit der Anzahl der Aufrufe, update()denn das ist rechenintensiv – 60 pro Sekunde sind völlig ausreichend. update()Der verbleibende Teil der Klasse

besteht aus Hilfsmethoden, die in Game game.js, Teil 3, verwendet werden update():

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()

getLeaderboard() Es ist ziemlich einfach – es sortiert die Spieler nach der Anzahl der Punkte, wählt die fünf besten aus und gibt für jeden den Benutzernamen und die Punktzahl zurück.

createUpdate() wird in update() verwendet, um Spiel-Updates zu erstellen, die an die Spieler übermittelt 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 die Daten über nahegelegene Spieler und Geschosse überträgt – es ist nicht notwendig, Informationen über entfernt gelegene Spielobjekte zu übermitteln!

3. Spielobjekte auf dem Server

In unserem Spiel sind Geschosse und Spieler tatsächlich sehr ähnlich: es handelt sich um abstrakte, runde, bewegliche Spielobjekte. Um diese Ähnlichkeit zwischen Spielern und Geschossen 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 ist nichts kompliziert. Diese Klasse wird eine gute Basis für Erweiterungen sein. Lassen Sie uns sehen, wie die Klasse Bullet nutzt 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 das Projektil zerstört werden soll
  update(dt) {
    super.update(dt);
    return this.x  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Implementierung Bullet ist sehr kurz! Wir haben hinzugefügt Object nur die folgenden Erweiterungen:

  • Die Verwendung des Pakets shortid für die zufällige Generierung id von Projektilen.
  • Hinzugefügt wurde das Feld parentID, um den Spieler verfolgen zu können, der dieses Projektil erstellt hat.
  • Das Rückgabewert wurde zu update(), das gleich true, ist, wenn das Projektil außerhalb der Arena ist (denken Sie daran, dass wir dies im vorherigen Abschnitt besprochen haben?).

Lassen Sie uns fortfahren 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;
  }

  // Gibt eine neu erstellte Kugel zurück oder null.
  update(dt) {
    super.update(dt);

    // Punkte aktualisieren
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Sicherstellen, 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));

    // Falls nötig eine Kugel abfeuern
    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 Kugeln, daher müssen in dieser Klasse noch einige Felder gespeichert werden. Ihre Methode update() leistet mehr Arbeit, insbesondere gibt sie nur die gerade erstellte Kugel zurück, wenn keine mehr übrig ist. fireCooldown (denken Sie daran, wir haben das im vorherigen Abschnitt besprochen?). Außerdem erweitert es die Methode serializeForUpdate(), da wir zusätzliche Felder für den Spieler im Spiel-Update einfügen müssen.

Die Existenz einer Basis-Klasse Object — ein wichtiger Schritt, um Wiederholungen im Code zu vermeiden. Zum Beispiel, ohne die Klasse Object muss jedes Spielelement eine einheitliche Implementierung haben distanceTo(), und die Synchronisation sämtlicher dieser Implementierungen in mehreren Dateien wäre ein Albtraum. Das wird besonders wichtig für große Projekte, wenn die Anzahl der erweiterten Object Klassen wächst.

4. Kollisions­erkennung

Das Einzige, was uns bleibt, ist zu erkennen, wann 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 Treffer 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. Glücklicherweise ist das nicht so schwer umzusetzen, denn

  • Alle kollidierenden Objekte sind Kreise, und dies ist die einfachste geometrische Form, um Kollisions­erkennung zu implementieren.
  • Wir haben bereits eine Methode distanceTo(), die wir im vorherigen Abschnitt in der Klasse implementiert haben Object.

So sieht unsere Implementierung zur 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 Kollisionskennung basiert auf der Tatsache, dass zwei Kreise kollidieren, wenn der Abstand zwischen ihren Mittelpunkten kleiner ist als die Summe ihrer Radien.Hier ist ein Fall, in dem der Abstand zwischen den Mittelpunkten der beiden Kreise genau der Summe ihrer Radien entspricht:

Erstellung eines Mehrspieler-Webspiels im .io-Genre
Es gibt jedoch noch einige Aspekte, auf die wir achten müssen:

  • Das Geschoss darf nicht den Spieler treffen, der es erzeugt hat. Das erreichen wir, indem wir bullet.parentID mit player.id.
  • Das Geschoss sollte im Extremfall nur einmal treffen, wenn es gleichzeitig mit mehreren Spielern kollidiert. Diese Aufgabe lösen wir mit dem Operator: breakSobald wir einen Spieler gefunden haben, der mit dem Geschoss kollidiert ist, brechen wir die Suche ab und gehen zum nächsten Geschoss über.

Ende

Das ist alles! Wir haben alles besprochen, was man wissen muss, um ein .io-Webspiel zu erstellen. Was kommt als Nächstes? Erstellen Sie Ihr eigenes .io-Spiel!

Der gesamte Beispielcode ist Open Source und auf Github.

Quelle: habr.com

Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server 🔥 Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server | ProHoster