Erstellen eines Multiplayer-.io-Webspiels

Erstellen eines Multiplayer-.io-Webspiels
Veröffentlicht im Jahr 2015 Agar.io wurde zum Vorläufer eines neuen Genres Spiele .iowas seitdem immer beliebter wird. Ich habe den Anstieg der Popularität von .io-Spielen persönlich erlebt: In den letzten drei Jahren habe ich das erlebt hat zwei Spiele dieses Genres erstellt und verkauft..

Falls Sie noch nie von diesen Spielen gehört haben: Hierbei handelt es sich um kostenlose Multiplayer-Webspiele, die einfach zu spielen sind (kein Konto erforderlich). Normalerweise treffen sie auf viele gegnerische Spieler in derselben Arena. Andere berühmte .io-Spiele: Slither.io и Diep.io.

In diesem Beitrag werden wir untersuchen, wie Erstellen Sie ein .io-Spiel von Grund auf. Dafür reichen lediglich Kenntnisse in Javascript aus: Sie müssen Dinge wie die Syntax verstehen ES6, Stichwort this и Promises. Auch wenn Ihre Javascript-Kenntnisse nicht perfekt sind, können Sie den Großteil des Beitrags dennoch verstehen.

Beispiel für ein .io-Spiel

Zur Lernhilfe verweisen wir auf Beispiel für ein .io-Spiel. Versuchen Sie es zu spielen!

Erstellen eines Multiplayer-.io-Webspiels
Das Spiel ist ganz 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 und dabei ihren Projektilen auszuweichen.

1. Kurzer Überblick/Aufbau des Projekts

empfehlen Quellcode herunterladen Beispielspiel, damit du mir folgen kannst.

Das Beispiel verwendet Folgendes:

  • Express ist das beliebteste Node.js-Webframework, das den Webserver des Spiels verwaltet.
  • socket.io – eine Websocket-Bibliothek zum Austausch von Daten zwischen einem Browser und einem Server.
  • Webpack - Modulmanager. Lesen Sie, warum Sie Webpack verwenden sollten. hier.

So sieht die Projektverzeichnisstruktur aus:

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

Öffentlichkeit/

Alles in einem Ordner public/ wird vom Server statisch übermittelt. IN public/assets/ enthält Bilder, die von unserem Projekt verwendet werden.

src /

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

2. Baugruppen/Projekteinstellungen

Wie oben erwähnt, verwenden wir den Modulmanager, um das Projekt zu erstellen. 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 ist der Einstiegspunkt des Javascript (JS)-Clients. Von hier aus startet Webpack und sucht rekursiv nach anderen importierten Dateien.
  • Das Ausgabe-JS unseres Webpack-Builds befindet sich im Verzeichnis dist/. Ich werde diese Datei unsere nennen js-Paket.
  • Wir gebrauchen Babel, und insbesondere die Konfiguration @babel/preset-env um unseren JS-Code für ältere Browser zu transpilieren.
  • Wir verwenden ein Plugin, um das gesamte von den JS-Dateien referenzierte CSS zu extrahieren und an einem Ort zu kombinieren. Ich werde ihn unseren nennen CSS-Paket.

Möglicherweise sind Ihnen seltsame Paketdateinamen aufgefallen '[name].[contenthash].ext'. Sie beinhalten Dateinamenersetzungen Webpaket: [name] wird durch den Namen des Eingabepunkts ersetzt (in unserem Fall this game), und [contenthash] wird durch einen Hash des Dateiinhalts ersetzt. Wir tun es Optimieren Sie das Projekt für Hashing - Sie können Browser anweisen, unsere JS-Pakete auf unbestimmte Zeit zwischenzuspeichern, weil Wenn sich ein Paket ändert, ändert sich auch sein Dateiname (Änderungen contenthash). Das Endergebnis ist der Name der Ansichtsdatei game.dbeee76e91a97d0c7207.js.

Datei webpack.common.js ist die Basiskonfigurationsdatei, die wir in die Entwicklungs- und fertigen Projektkonfigurationen importieren. Hier ist eine Beispielentwicklungskonfiguration:

webpack.dev.js

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

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

Aus Effizienzgründen nutzen wir im Entwicklungsprozess webpack.dev.js, und wechselt zu webpack.prod.jszur Optimierung der Paketgrößen bei der Bereitstellung in der Produktion.

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. Die Einrichtung ist einfach: Zunächst muss das System installiert sein Knoten и NPM. Als nächstes müssen Sie Folgendes tun

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

und schon kann es losgehen! Um den Entwicklungsserver zu starten, führen Sie ihn einfach aus

$ npm run develop

und gehen Sie zum Webbrowser localhost: 3000. Der Entwicklungsserver erstellt die JS- und CSS-Pakete automatisch neu, wenn sich der Code ändert – aktualisieren Sie einfach die Seite, um alle Änderungen zu sehen!

3. Kundeneinstiegspunkte

Kommen wir zum Spielcode selbst. Zuerst brauchen wir eine Seite index.html, beim Besuch der Website lädt der Browser diese zuerst. Unsere Seite wird ziemlich einfach sein:

index.html

Ein Beispiel für ein .io-Spiel  SPIELEN

Dieses Codebeispiel wurde aus Gründen der Übersichtlichkeit leicht vereinfacht, und ich werde das Gleiche mit vielen anderen Beitragsbeispielen tun. Der vollständige Code kann jederzeit unter eingesehen werden Github.

Wir haben:

  • HTML5-Canvas-Element (<canvas>), die wir zum Rendern des Spiels verwenden werden.
  • <link> um unser CSS-Paket hinzuzufügen.
  • <script> um unser Javascript-Paket hinzuzufügen.
  • Hauptmenü mit Benutzername <input> und die PLAY-Taste (<button>).

Nach dem Laden der Homepage beginnt der Browser mit der Ausführung von Javascript-Code, beginnend mit der Einstiegspunkt-JS-Datei: src/client/index.js.

index.js

import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';

import './css/main.css';

const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Das mag kompliziert klingen, aber hier ist nicht viel los:

  1. Importieren mehrerer anderer JS-Dateien.
  2. CSS-Import (damit Webpack sie in unser CSS-Paket aufnehmen kann).
  3. Starten connect() um eine Verbindung mit dem Server herzustellen und auszuführen downloadAssets() um Bilder herunterzuladen, die zum Rendern des Spiels benötigt werden.
  4. Nach Abschluss von Stufe 3 Das Hauptmenü wird angezeigt (playMenu).
  5. Festlegen des Handlers für das 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 Hauptbestandteil unserer Client-Server-Logik sind die Dateien, die von der Datei importiert wurden index.js. Jetzt werden wir sie alle der Reihe nach betrachten.

4. Austausch von Kundendaten

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

Wir werden eine Datei haben src/client/networking.jswer wird sich darum kümmern alle Kommunikation mit dem Server:

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('Connected to server!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    // Register callbacks
    socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
    socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
  })
);

export const play = username => {
  socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};

export const updateDirection = dir => {
  socket.emit(Constants.MSG_TYPES.INPUT, dir);
};

Auch dieser Code wurde aus Gründen der Übersichtlichkeit leicht gekürzt.

In dieser Datei gibt es drei Hauptaktionen:

  • Wir versuchen, eine Verbindung zum Server herzustellen. connectedPromise nur erlaubt, wenn wir eine Verbindung hergestellt haben.
  • Wenn die Verbindung erfolgreich ist, registrieren wir Callback-Funktionen (processGameUpdate() и onGameOver()) für Nachrichten, die wir vom Server empfangen können.
  • Wir exportieren play() и updateDirection()damit andere Dateien sie verwenden können.

5. Client-Rendering

Es ist Zeit, das Bild auf dem Bildschirm anzuzeigen!

…aber bevor wir das tun können, müssen wir alle dafür benötigten Bilder (Ressourcen) herunterladen. 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 gar nicht so schwer umzusetzen! Die Hauptidee besteht darin, ein Objekt zu speichern assets, wodurch der Schlüssel des Dateinamens an den Wert des Objekts gebunden wird Image. Wenn die Ressource geladen ist, speichern wir sie in einem Objekt assets für einen schnellen Zugriff in der Zukunft. Wann darf jede einzelne Ressource heruntergeladen werden (d. h. alle Ressourcen), erlauben wir downloadPromise.

Nachdem Sie die Ressourcen heruntergeladen haben, können Sie mit dem Rendern beginnen. Wie bereits erwähnt, verwenden wir zum Zeichnen auf einer Webseite HTML5-Leinwand (<canvas>). Unser Spiel ist ziemlich einfach, wir müssen also nur Folgendes zeichnen:

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

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

render.js

import { getAsset } from './assets';
import { getCurrentState } from './state';

const Constants = require('../shared/constants');
const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants;

// Get the canvas graphics context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Make the canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Draw background
  renderBackground(me.x, me.y);

  // Draw all bullets
  bullets.forEach(renderBullet.bind(null, me));

  // Draw all players
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Helper functions here excluded

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

Aus Gründen der Übersichtlichkeit wurde dieser Code ebenfalls gekürzt.

render() ist die Hauptfunktion dieser Datei. startRendering() и stopRendering() Steuern Sie die Aktivierung der Renderschleife mit 60 FPS.

Konkrete Implementierungen einzelner Rendering-Hilfsfunktionen (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 verwenden getAsset(), das zuvor in gesehen wurde asset.js!

Wenn Sie daran interessiert sind, andere Rendering-Helfer kennenzulernen, lesen Sie den Rest src/client/render.js.

6. Kundeneingabe

Es ist Zeit, ein Spiel zu machen spielbar! Das Steuerungsschema wird sehr einfach sein: Um die Bewegungsrichtung zu ändern, können Sie die Maus (auf einem Computer) verwenden oder den Bildschirm berühren (auf einem mobilen Gerät). Um dies umzusetzen, werden wir uns registrieren Ereignislistener für Maus- und Touch-Events.
Ich werde mich um all das kümmern 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() и onTouchInput() sind Ereignis-Listener, die aufrufen updateDirection() (von networking.js), wenn ein Eingabeereignis auftritt (z. B. wenn die Maus bewegt wird). updateDirection() Verarbeitet die Nachrichtenübermittlung an den Server, der das Eingabeereignis verarbeitet und den Spielstatus entsprechend aktualisiert.

7. Kundenstatus

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

Das letzte Puzzleteil, das zur Vervollständigung des Client/Server-Codes benötigt wird, ist Zustand. Erinnern Sie sich an den Codeausschnitt aus dem Abschnitt „Client-Rendering“?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() sollte in der Lage sein, uns den aktuellen Stand des Spiels im Client mitzuteilen zu jedem Zeitpunkt basierend auf vom Server empfangenen Updates. 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: Server-Zeitstempel, der angibt, wann dieses Update erstellt wurde.
  • me: Informationen über den Player, der dieses Update erhält.
  • Extras: Eine Reihe von Informationen über andere Spieler, die am selben Spiel teilnehmen.
  • Kugeln: eine Reihe von Informationen über Projektile im Spiel.
  • Leaderboard: Aktuelle Bestenlistendaten. In diesem Beitrag werden wir sie nicht berücksichtigen.

7.1 Naiver Klientenzustand

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

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Schön und klar! Aber wenn es nur so einfach wäre. Einer der Gründe, warum diese Implementierung problematisch ist: Es begrenzt die Rendering-Framerate auf die Server-Taktrate.

Bildrate: Anzahl der Frames (d. h. Aufrufe). render()) pro Sekunde oder FPS. Spiele streben in der Regel danach, mindestens 60 FPS zu erreichen.

Tick-Rate: Die Häufigkeit, mit der der Server Spielaktualisierungen an Clients sendet. Sie ist oft 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, werden die FPS im Grunde nie über 30 steigen, weil Wir bekommen nie mehr als 30 Updates pro Sekunde vom Server. Auch wenn wir anrufen render() 60 Mal pro Sekunde zeichnet die Hälfte dieser Aufrufe einfach das Gleiche neu und bewirkt im Grunde nichts. Ein weiteres Problem bei der naiven Implementierung besteht darin, dass es anfällig für Verzögerungen. Bei idealer Internetgeschwindigkeit erhält der Client genau alle 33 ms (30 pro Sekunde) ein Spielupdate:

Erstellen eines Multiplayer-.io-Webspiels
Leider ist nichts perfekt. Ein realistischeres Bild wäre:
Erstellen eines Multiplayer-.io-Webspiels
Die naive Implementierung ist praktisch der schlechteste Fall, wenn es um die Latenz geht. Wenn ein Spielupdate mit einer Verzögerung von 50 ms empfangen wird, dann Client bleibt stehen zusätzliche 50 ms, da immer noch der Spielstatus aus dem vorherigen Update gerendert wird. Sie können sich vorstellen, wie unangenehm das für den Spieler ist: Willkürliches Bremsen führt dazu, dass sich das Spiel ruckartig und instabil anfühlt.

7.2 Verbesserter Client-Status

Wir werden einige Verbesserungen an der naiven Implementierung vornehmen. Zuerst verwenden wir Rendering-Verzögerung für 100 ms. Das bedeutet, dass der „aktuelle“ Status des Clients immer 100 ms hinter dem Status des Spiels auf dem Server zurückbleibt. Zum Beispiel, wenn die Uhrzeit auf dem Server ist 150, dann gibt der Client den Zustand wieder, in dem sich der Server zu diesem Zeitpunkt befand 50:

Erstellen eines Multiplayer-.io-Webspiels
Dies gibt uns einen Puffer von 100 ms, um unvorhersehbare Spielaktualisierungszeiten zu überstehen:

Erstellen eines Multiplayer-.io-Webspiels
Der Lohn dafür wird dauerhaft sein Input-Lag für 100 ms. Dies ist ein kleiner Einbußen für ein reibungsloses Gameplay – die meisten Spieler (insbesondere Gelegenheitsspieler) werden diese Verzögerung nicht einmal bemerken. Es ist für Menschen viel einfacher, sich an eine konstante Latenz von 100 ms zu gewöhnen, als mit einer unvorhersehbaren Latenz zu spielen.

Wir können auch eine andere Technik namens verwenden clientseitige Vorhersage, was die wahrgenommene Latenz gut reduziert, aber in diesem Beitrag nicht behandelt wird.

Eine weitere Verbesserung, die wir verwenden, ist lineare Interpolation. Aufgrund der Renderverzögerung sind wir der aktuellen Zeit im Client normalerweise mindestens ein Update voraus. Wenn angerufen getCurrentState(), können wir ausführen lineare Interpolation zwischen Spielaktualisierungen kurz vor und nach der aktuellen Zeit im Client:

Erstellen eines Multiplayer-.io-Webspiels
Dies löst das Problem der Bildrate: Wir können jetzt einzigartige Frames mit jeder gewünschten Bildrate rendern!

7.3 Implementieren eines erweiterten Client-Status

Implementierungsbeispiel in src/client/state.js Verwendet sowohl Renderverzögerung als auch lineare Interpolation, jedoch nicht für lange. Teilen wir den Code in zwei Teile auf. Hier ist der erste:

state.js Teil 1

const RENDER_DELAY = 100;

const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;

export function initState() {
  gameStart = 0;
  firstServerTimestamp = 0;
}

export function processGameUpdate(update) {
  if (!firstServerTimestamp) {
    firstServerTimestamp = update.t;
    gameStart = Date.now();
  }
  gameUpdates.push(update);

  // Keep only one game update before the current server time
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

function currentServerTime() {
  return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}

// Returns the index of the base update, the first game update before
// current server time, or -1 if N/A.
function getBaseUpdate() {
  const serverTime = currentServerTime();
  for (let i = gameUpdates.length - 1; i >= 0; i--) {
    if (gameUpdates[i].t <= serverTime) {
      return i;
    }
  }
  return -1;
}

Der erste Schritt besteht darin, herauszufinden, was currentServerTime(). Wie wir bereits gesehen haben, enthält jedes Spielupdate einen Server-Zeitstempel. Wir möchten die Renderlatenz nutzen, um das Bild 100 ms hinter dem Server zu rendern, aber Wir werden nie die aktuelle Uhrzeit auf dem Server erfahren, da wir nicht wissen können, wie lange es gedauert hat, bis eines der Updates bei uns eintraf. Das Internet ist unberechenbar und seine Geschwindigkeit kann stark variieren!

Um dieses Problem zu umgehen, können wir eine vernünftige Näherung verwenden: wir Stellen Sie sich vor, das erste Update wäre sofort eingetroffen. Wenn das wahr wäre, dann wüssten wir die Serverzeit zu diesem bestimmten Zeitpunkt! Wir speichern den Zeitstempel des Servers in firstServerTimestamp und behalte unser lokal (Client-)Zeitstempel zum selben Zeitpunkt in gameStart.

Oh, Moment mal. Sollte es nicht Serverzeit = Clientzeit sein? Warum unterscheiden wir zwischen „Server-Zeitstempel“ und „Client-Zeitstempel“? Das ist eine tolle Frage! Es stellt sich heraus, dass sie nicht dasselbe sind. Date.now() gibt unterschiedliche Zeitstempel auf dem Client und dem Server zurück und hängt von den lokalen Faktoren dieser Maschinen ab. Gehen Sie niemals davon aus, dass die Zeitstempel auf allen Maschinen gleich sind.

Jetzt verstehen wir, was es bedeutet currentServerTime(): es kehrt zurück Der Server-Zeitstempel der aktuellen Renderzeit. Mit anderen Worten, dies ist die aktuelle Zeit des Servers (firstServerTimestamp <+ (Date.now() - gameStart)) minus Renderverzögerung (RENDER_DELAY).

Schauen wir uns nun an, wie wir mit Spielaktualisierungen umgehen. Wenn es vom Update-Server empfangen wird, wird es aufgerufen processGameUpdate()und wir speichern das neue Update in einem Array gameUpdates. Um die Speichernutzung zu überprüfen, entfernen wir anschließend alle alten Updates Basis-Updateweil wir sie nicht mehr brauchen.

Was ist ein „Basisupdate“? Das Das erste Update finden wir, indem wir von der aktuellen Zeit des Servers aus rückwärts gehen. Erinnern Sie sich an dieses Diagramm?

Erstellen eines Multiplayer-.io-Webspiels
Das Spielupdate direkt links neben „Client-Renderzeit“ ist das Basisupdate.

Wozu dient das Basisupdate? Warum können wir Aktualisierungen auf die Baseline verwerfen? Um das herauszufinden, lassen Sie uns endlich über die Umsetzung nachdenken 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 bearbeiten drei Fälle:

  1. base < 0 bedeutet, dass es bis zum aktuellen Renderzeitpunkt keine Aktualisierungen gibt (siehe obige Implementierung). getBaseUpdate()). Dies kann aufgrund von Renderverzögerungen direkt zu Beginn des Spiels passieren. In diesem Fall verwenden wir das zuletzt erhaltene Update.
  2. base ist das neueste Update, das wir haben. Dies kann an einer Netzwerkverzögerung oder einer schlechten Internetverbindung liegen. In diesem Fall verwenden wir auch das neueste Update, das uns vorliegt.
  3. Wir haben sowohl vor als auch nach der aktuellen Renderzeit ein Update, damit wir das können interpolieren!

Alles, was noch drin ist state.js ist eine Implementierung der linearen Interpolation, die einfache (aber langweilige) Mathematik ist. Wenn Sie es selbst erkunden möchten, dann öffnen Sie es state.js auf Github.

Teil 2. Backend-Server

In diesem Teil werfen wir einen Blick auf das Node.js-Backend, das unsere steuert Beispiel für ein .io-Spiel.

1. Server-Einstiegspunkt

Zur Verwaltung des Webservers verwenden wir ein beliebtes Webframework namens Node.js Express. Es wird durch unsere Server-Einstiegspunktdatei konfiguriert src/server/server.js:

server.js Teil 1

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');

// Setup an Express server
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Setup Webpack for development
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Static serve the dist/ folder in production
  app.use(express.static('dist'));
}

// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

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

  • Zu verwenden webpack-dev-middleware um unsere Entwicklungspakete automatisch neu zu erstellen, oder
  • Ordner statisch übertragen dist/, in die Webpack unsere Dateien nach dem Produktions-Build schreibt.

Eine weitere wichtige Aufgabe server.js besteht darin, den Server einzurichten socket.iodas einfach eine Verbindung zum Express-Server herstellt:

server.js Teil 2

const socketio = require('socket.io');
const Constants = require('../shared/constants');

// Setup Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

// Setup socket.io
const io = socketio(server);

// Listen for socket.io connections
io.on('connection', socket => {
  console.log('Player connected!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

Nachdem wir erfolgreich eine socket.io-Verbindung zum Server hergestellt haben, richten wir Event-Handler für den neuen Socket ein. Ereignishandler verarbeiten von Clients empfangene Nachrichten, indem sie sie an ein Singleton-Objekt delegieren game:

server.js Teil 3

const Game = require('./game');

// ...

// Setup the Game
const game = new Game();

function joinGame(username) {
  game.addPlayer(this, username);
}

function handleInput(dir) {
  game.handleInput(this, dir);
}

function onDisconnect() {
  game.removePlayer(this);
}

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

2. Spieleserver

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

Beginnen wir mit der ersten Aufgabe, der Spielerverwaltung.

game.js Teil 1

const Constants = require('../shared/constants');
const Player = require('./player');

class Game {
  constructor() {
    this.sockets = {};
    this.players = {};
    this.bullets = [];
    this.lastUpdateTime = Date.now();
    this.shouldSendUpdate = false;
    setInterval(this.update.bind(this), 1000 / 60);
  }

  addPlayer(socket, username) {
    this.sockets[socket.id] = socket;

    // Generate a position to start this player at.
    const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    this.players[socket.id] = new Player(socket.id, username, x, y);
  }

  removePlayer(socket) {
    delete this.sockets[socket.id];
    delete this.players[socket.id];
  }

  handleInput(socket, dir) {
    if (this.players[socket.id]) {
      this.players[socket.id].setDirection(dir);
    }
  }

  // ...
}

In diesem Spiel identifizieren wir die Spieler anhand des Spielfelds id ihren socket.io-Socket (wenn Sie verwirrt sind, gehen Sie zurück zu server.js). Socket.io selbst weist jedem Socket ein Unikat zu idWir brauchen uns also darüber keine Sorgen zu machen. Ich werde ihn anrufen Spieler-ID.

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

  • sockets ist ein Objekt, das die Spieler-ID an den Socket bindet, der dem Spieler zugeordnet ist. Es ermöglicht uns, in einer konstanten Zeit über ihre Spieler-IDs auf Sockets zuzugreifen.
  • players ist ein Objekt, das die Spieler-ID an das Code>Player-Objekt bindet

bullets ist ein Array von Objekten Bullet, die keine bestimmte Reihenfolge hat.
lastUpdateTime ist der Zeitstempel der letzten Aktualisierung des Spiels. Wir werden in Kürze sehen, wie es verwendet wird.
shouldSendUpdate ist eine Hilfsvariable. Wir werden auch bald seine Verwendung sehen.
Methoden addPlayer(), removePlayer() и handleInput() Es besteht kein Grund zur Erklärung, sie werden in verwendet server.js. Wenn Sie Ihr Gedächtnis auffrischen müssen, gehen Sie etwas weiter nach oben.

Letzte Linie constructor() startet Aktualisierungszyklus Spiele (mit einer Frequenz von 60 Updates/s):

game.js Teil 2

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

class Game {
  // ...

  update() {
    // Calculate time elapsed
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Update each bullet
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        // Destroy this bullet
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

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

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

    // Check if any players are dead
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp <= 0) {
        socket.emit(Constants.MSG_TYPES.GAME_OVER);
        this.removePlayer(socket);
      }
    });

    // Send a game update to each player every other time
    if (this.shouldSendUpdate) {
      const leaderboard = this.getLeaderboard();
      Object.keys(this.sockets).forEach(playerID => {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
          Constants.MSG_TYPES.GAME_UPDATE,
          this.createUpdate(player, leaderboard),
        );
      });
      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true;
    }
  }

  // ...
}

Verfahren update() enthält vielleicht den wichtigsten Teil der serverseitigen Logik. Dies geschieht in der Reihenfolge:

  1. Berechnet wie lange dt seit dem letzten vergangen update().
  2. Aktualisiert jedes Projektil und zerstört es bei Bedarf. Wir werden die Implementierung dieser Funktionalität später sehen. Für den Moment reicht es uns, das zu wissen bullet.update() kehrt zurück truewenn das Projektil zerstört werden sollte (er trat aus der Arena).
  3. Aktualisiert jeden Spieler und erzeugt bei Bedarf ein Projektil. Wir werden diese Implementierung auch später sehen - player.update() kann ein Objekt zurückgeben Bullet.
  4. Prüft auf Kollisionen zwischen Projektilen und Spielern applyCollisions(), das eine Reihe von Projektilen zurückgibt, die Spieler treffen. Für jedes zurückgegebene Projektil erhöhen wir die Punkte des Spielers, der es abgefeuert hat (mit player.onDealtDamage()) und entfernen Sie dann das Projektil aus dem Array bullets.
  5. Benachrichtigt und zerstört alle getöteten Spieler.
  6. Sendet ein Spiel-Update an alle Spieler jede Sekunde Zeiten, wenn angerufen update(). Dies hilft uns, den Überblick über die oben erwähnte Hilfsvariable zu behalten. shouldSendUpdate. Als update() 60-mal/s aufgerufen, wir senden Spiel-Updates 30-mal/s. Auf diese Weise, Taktfrequenz Der Servertakt beträgt 30 Takte/s (wir haben im ersten Teil über Taktraten gesprochen).

Warum nur Spiel-Updates senden? durch die Zeit ? Kanal speichern. 30 Spielaktualisierungen pro Sekunde sind viel!

Warum nicht einfach anrufen? update() 30 Mal pro Sekunde? Zur Verbesserung der Spielsimulation. Je öfter angerufen wird update(), desto genauer wird die Spielsimulation. Aber lassen Sie sich nicht zu sehr von der Anzahl der Herausforderungen mitreißen. update(), da dies eine rechenintensive Aufgabe ist – 60 pro Sekunde reichen aus.

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

game.js Teil 3

class Game {
  // ...

  getLeaderboard() {
    return Object.values(this.players)
      .sort((p1, p2) => p2.score - p1.score)
      .slice(0, 5)
      .map(p => ({ username: p.username, score: Math.round(p.score) }));
  }

  createUpdate(player, leaderboard) {
    const nearbyPlayers = Object.values(this.players).filter(
      p => p !== player && p.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );
    const nearbyBullets = this.bullets.filter(
      b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );

    return {
      t: Date.now(),
      me: player.serializeForUpdate(),
      others: nearbyPlayers.map(p => p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() Ziemlich einfach: Es sortiert die Spieler nach Punktestand, nimmt die ersten fünf und gibt für jeden den Benutzernamen und den Punktestand zurück.

createUpdate() benutzt in update() um Spielaktualisierungen zu erstellen, die an Spieler verteilt werden. Seine Hauptaufgabe besteht darin, Methoden aufzurufen serializeForUpdate()für Klassen implementiert Player и Bullet. Beachten Sie, dass nur Daten an jeden Spieler weitergegeben werden nächste Spieler und Projektile – es besteht keine Notwendigkeit, Informationen über Spielobjekte zu übertragen, die weit vom Spieler entfernt sind!

3. Spielobjekte auf dem Server

In unserem Spiel sind sich Projektile und Spieler tatsächlich sehr ähnlich: Es handelt sich um abstrakte, runde, bewegliche Spielobjekte. Um diese Ähnlichkeit zwischen Spielern und Projektilen auszunutzen, 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 Kompliziertes los. Diese Klasse wird ein guter Ankerpunkt 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;
  }

  // Returns true if the bullet should be destroyed
  update(dt) {
    super.update(dt);
    return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
  }
}

Implementierung Bullet sehr kurze! Wir haben ergänzt Object nur die folgenden Erweiterungen:

  • Ein Paket verwenden kurz zur Zufallsgenerierung id Schale.
  • Ein Feld hinzufügen parentIDdamit Sie den Spieler verfolgen können, der dieses Projektil erstellt hat.
  • Hinzufügen eines Rückgabewerts zu update(), was gleich ist truewenn sich das Projektil außerhalb der Arena befindet (erinnern Sie sich, dass wir im letzten Abschnitt darüber gesprochen haben?).

Lass uns weitergehen 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;
  }

  // Returns a newly created bullet, or null.
  update(dt) {
    super.update(dt);

    // Update score
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Make sure the player stays in bounds
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    // Fire a bullet, if needed
    this.fireCooldown -= dt;
    if (this.fireCooldown <= 0) {
      this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN;
      return new Bullet(this.id, this.x, this.y, this.direction);
    }
    return null;
  }

  takeBulletDamage() {
    this.hp -= Constants.BULLET_DAMAGE;
  }

  onDealtDamage() {
    this.score += Constants.SCORE_BULLET_HIT;
  }

  serializeForUpdate() {
    return {
      ...(super.serializeForUpdate()),
      direction: this.direction,
      hp: this.hp,
    };
  }
}

Spieler sind komplexer als Projektile, daher sollten in dieser Klasse ein paar Felder mehr hinterlegt werden. Seine Methode update() macht viel Arbeit, insbesondere gibt es das neu erstellte Projektil zurück, wenn keines mehr übrig sind fireCooldown (Erinnern Sie sich, dass wir im vorherigen Abschnitt darüber gesprochen haben?). Außerdem erweitert es die Methode serializeForUpdate(), weil wir im Spielupdate zusätzliche Felder für den Spieler einbinden müssen.

Eine Basisklasse haben Object - ein wichtiger Schritt, um die Wiederholung von Code zu vermeiden. Zum Beispiel kein Unterricht Object Jedes Spielobjekt muss die gleiche Implementierung haben distanceTo(), und das Kopieren und Einfügen all dieser Implementierungen in mehrere Dateien wäre ein Albtraum. Dies wird besonders bei großen Projekten wichtig.wenn die Zahl der Erweiterung Object Klassen wachsen.

4. Kollisionserkennung

Uns bleibt nur noch zu erkennen, wann die Projektile die Spieler treffen! Merken Sie sich diesen Code aus der Methode update() in der Klasse Game:

game.js

const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // ...

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

    // ...
  }
}

Wir müssen die Methode implementieren applyCollisions(), das alle Projektile zurückgibt, die Spieler treffen. Zum Glück ist es nicht so schwer, denn

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

So sieht unsere Implementierung der Kollisionserkennung aus:

Kollisionen.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 Mittelpunkten kleiner als die Summe ihrer Radien ist. Hier ist der Fall, dass der Abstand zwischen den Mittelpunkten zweier Kreise genau gleich der Summe ihrer Radien ist:

Erstellen eines Multiplayer-.io-Webspiels
Hier sind noch einige weitere Aspekte zu berücksichtigen:

  • Das Projektil darf den Spieler, der es erstellt hat, nicht treffen. Dies kann durch einen Vergleich erreicht werden bullet.parentID с player.id.
  • Im Grenzfall, dass mehrere Spieler gleichzeitig kollidieren, darf das Projektil nur einmal treffen. Wir werden dieses Problem mit dem Operator lösen break: Sobald der Spieler gefunden wird, der mit dem Projektil kollidiert, stoppen wir die Suche und gehen zum nächsten Projektil über.

Ende

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

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

Source: habr.com

Kommentar hinzufügen