Wie wir den Netzwerkcode des mobilen PvP-Shooters geschrieben haben: Spielersynchronisation auf dem Client

Published on July 03, 2018

Wie wir den Netzwerkcode des mobilen PvP-Shooters geschrieben haben: Spielersynchronisation auf dem Client

    In einem früheren Artikel haben wir die in unserem neuen Projekt verwendeten Technologien besprochen - den rasanten Shooter für mobile Geräte. Jetzt möchte ich mitteilen, wie der Client-Teil des Netzwerkcodes des zukünftigen Spiels angeordnet ist, auf welche Schwierigkeiten wir gestoßen sind und wie wir sie gelöst haben.




    Im Allgemeinen haben sich die Ansätze zur Erstellung schneller Multiplayer-Spiele in den letzten 20 Jahren nicht wesentlich geändert. Es gibt verschiedene Methoden in der Netzwerkcode-Architektur:

    1. Berechnen des Zustands der Welt auf dem Server und Anzeigen der Ergebnisse auf dem Client ohne Vorhersage für den lokalen Spieler und mit der Möglichkeit, die Eingabe (Eingabe) des Spielers zu verlieren. Dieser Ansatz wird übrigens in unserem anderen Entwicklungsprojekt verwendet - darüber können Sie hier lesen .
    2. Gleichschritt .
    3. Synchronisieren Sie den Zustand der Welt ohne deterministische Logik mit Vorhersage für einen lokalen Spieler.
    4. Eingabesynchronisation mit vollständig deterministischer Logik und Vorhersage für einen lokalen Spieler.

    Die Besonderheit liegt in der Tatsache, dass die Reaktionsfähigkeit der Steuerung bei Schützen am wichtigsten ist - der Spieler drückt den Knopf (oder bewegt den Joystick) und möchte sofort das Ergebnis seiner Aktion sehen. Erstens, weil sich der Zustand der Welt in solchen Spielen sehr schnell ändert und es notwendig ist, sofort auf die Situation zu reagieren.

    Infolgedessen passte das Projekt nicht zu den Ansätzen ohne den Mechanismus der Vorhersage der Aktionen des lokalen Spielers (Vorhersage), und wir stoppten bei der Methode der Synchronisierung des Weltzustands ohne deterministische Logik.

    Plus-Ansatz: Weniger Komplexität bei der Implementierung als bei der Synchronisationsmethode für den Austausch von Eingaben.
    Minus:Erhöhung des Verkehrsaufkommens beim Senden des gesamten Zustands der Welt an den Kunden. Wir mussten verschiedene Techniken zur Verkehrsoptimierung anwenden, um das Spiel im Mobilfunknetz stabil zu halten.

    Im Zentrum der Gameplay-Architektur steht ECS, über das wir bereits gesprochen haben . Mit dieser Architektur können Sie bequem Daten über die Spielwelt speichern, serialisieren, kopieren und über das Netzwerk übertragen. Und auch, um den gleichen Code sowohl auf dem Client als auch auf dem Server auszuführen.

    Die Simulation der Spielwelt erfolgt mit einer festen Frequenz von 30 Ticks pro Sekunde. Auf diese Weise können Sie die Verzögerung bei der Eingabe durch den Player verringern und die Interpolation fast nicht verwenden, um den Zustand der Welt zu visualisieren. Es gibt jedoch einen wesentlichen Nachteil, der bei der Entwicklung eines solchen Systems berücksichtigt werden sollte: Damit das Vorhersage-System des lokalen Spielers ordnungsgemäß funktioniert, muss der Client die Welt mit derselben Frequenz wie der Server simulieren. Und wir haben viel Zeit aufgewendet, um die Simulation für die Zielgeräte ausreichend zu optimieren.

    Der Mechanismus zur Vorhersage der Aktionen eines lokalen Spielers (Vorhersage)


    Der Vorhersagemechanismus auf dem Client wird auf der Basis von ECS implementiert, da sowohl auf dem Client als auch auf dem Server dieselben Systeme ausgeführt werden. Der Client führt jedoch nicht alle Systeme aus, sondern nur diejenigen, die für den lokalen Spieler verantwortlich sind und keine tatsächlichen Daten über andere Spieler benötigen.

    Beispiellisten von Systemen, die auf dem Client und dem Server ausgeführt werden:



    Derzeit werden auf dem Client ungefähr 30 Systeme ausgeführt, die eine Spielervorhersage liefern, und auf dem Server werden ungefähr 80 Systeme ausgeführt. Aber wir erfüllen nicht die Vorhersagen von Dingen wie Schaden zufügen, Fähigkeiten einsetzen oder Verbündete behandeln. Es gibt zwei Probleme mit dieser Mechanik:

    1. Der Client weiß nichts über das Eintreten in andere Spieler und Vorhersagen über Schäden oder Heilung weichen fast immer von den Daten auf dem Server ab.
    2. Das lokale Erstellen neuer Entitäten (Schüsse, Projektile, einzigartige Fähigkeiten), die von einem Spieler erzeugt werden, führt zu dem Problem der Übereinstimmung mit den auf dem Server erstellten Entitäten.

    Bei solchen Mechaniken ist die Verzögerung auf andere Weise vor dem Spieler verborgen.

    Beispiel: Wir zeichnen den Effekt, einen Schuss sofort zu treffen, und aktualisieren das Leben des Feindes erst, nachdem wir die Bestätigung des Treffers vom Server erhalten haben.

    Das allgemeine Schema des Netzwerkcodes im Projekt




    Client und Server synchronisieren die Zeit nach Ticknummern. Aufgrund der Tatsache, dass die Datenübertragung über das Netzwerk einige Zeit in Anspruch nimmt, ist der Client dem Server immer um die Hälfte der Größe des RTT + -Eingabepuffers auf dem Server voraus . Das obige Diagramm zeigt, dass der Client eine Eingabe für Tick 20 (a) sendet. Gleichzeitig wird ein Häkchen von 15 (b) auf dem Server verarbeitet. Wenn der Client den Server erreicht, wird ein Häkchen von 20 auf dem Server verarbeitet.

    Der gesamte Vorgang besteht aus den folgenden Schritten: Der Client sendet die Eingaben des Spielers an den Server (a). Diese Eingaben werden auf dem Server nach HRTT + Eingabepuffergröße (b) verarbeitet Der Server sendet den resultierenden Zustand der Welt an den (die) Client (s). → Der Client wendet den bestätigten Zustand der Welt vom Server bis zur Zeit RTT + Eingabepuffergröße + Interpolationspuffergröße des Spielzustands (d) an.

    Nachdem der Client vom Server (d) einen neuen bestätigten Status der Welt erhalten hat, muss er den Abgleich durchführen. Tatsache ist, dass der Kunde die Vorhersage der Welt nur auf der Grundlage der Eingabe des lokalen Spielers durchführt. Die Eingänge der anderen Spieler sind ihm nicht bekannt. Und wenn ein Spieler den Zustand der Welt auf einem Server berechnet, befindet er sich möglicherweise in einem anderen Zustand als vom Kunden vorhergesagt. Dies kann passieren, wenn ein Spieler betäubt oder getötet wird.

    Der Abstimmungsprozess besteht aus zwei Teilen:

    1. Vergleich des vorhergesagten Zustands der Welt für Tick N, vom Server abgerufen. Am Vergleich sind nur Daten beteiligt, die sich auf den lokalen Spieler beziehen. Die Daten für den Rest der Welt werden immer aus dem Serverstatus entnommen und nehmen nicht an der Abstimmung teil.
    2. Während des Vergleichs können zwei Fälle auftreten:

    - Wenn der vorhergesagte Zustand der Welt mit der Bestätigung durch den Server übereinstimmt, simuliert der Client die Welt weiterhin auf die übliche Weise, indem er die vorhergesagten Daten für den lokalen Spieler und die neuen Daten für den Rest der Welt verwendet.
    - Wenn der vorhergesagte Status nicht übereinstimmt, verwendet der Client den gesamten Server-Status der Welt und den Verlauf der Eingaben vom Client und zählt den neuen vorhergesagten Status der Welt des Spielers nach.

    Im Code sieht es so aus:
    GameState Reconcile(int currentTick, ServerGameStateData serverStateData,   GameState currentState, uint playerID)
    {
      var serverState =  serverStateData.GameState;
      var serverTick = serverState.Time;
      var predictedState = _localStateHistory.Get(serverTick);
      //if predicted state matches server last state use server predicted state with predicted player
      if (_gameStateComparer.IsSame(predictedState, serverState, playerID))
      {
         _tempState.Copy(serverState);
         _gameStateCopier.CopyPlayerEntities(currentState, _tempState, playerID);
         return _localStateHistory.Put(_tempState); // replace predicted state with correct server state
      }
      //if predicted state doesn't match server state, reapply local inputs to server state
      var last = _localStateHistory.Put(serverState); // replace wrong predicted state with correct server state
      for (var i = serverTick; i < currentTick; i++) 
      {
         last = _prediction.Predict(last); // resimulate all wrong states
      }
      return last;
    }


    Der Vergleich zweier Staaten der Welt erfolgt nur für diejenigen Daten, die sich auf den lokalen Spieler beziehen und am Vorhersage-System teilnehmen. Der Datenabruf erfolgt nach Spieler-ID.

    Vergleichsmethode:
    public bool IsSame(GameState s1, GameState s2, uint avatarId)
        {
            if (s1 == null && s2 != null ||  s1 != null && s2 == null)
                return false;
            if (s1 == null && s2 == null)
                return false;
            var entity1 = s1.WorldState[avatarId];
            var entity2 = s2.WorldState[avatarId];
            if (entity1 == null && entity2 == null)
                return false;
            if (entity1 == null || entity2 == null)
                return false;
            if (s1.Time != s2.Time)
                return false;
            if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId])
                return false;
            foreach (var s1Weapon in s1.WorldState.Weapon)
            {
                if (s1Weapon.Value.Owner.Id != avatarId)
                    continue;
                var s2Weapon = s2.WorldState.Weapon[s1Weapon.Key];
                if (s1Weapon.Value != s2Weapon)
                    return false;
                var s1Ammo = s1.WorldState.WeaponAmmo[s1Weapon.Key];
                var s2Ammo = s2.WorldState.WeaponAmmo[s1Weapon.Key];
                if (s1Ammo != s2Ammo)
                    return false;
                var s1Reload = s1.WorldState.WeaponReloading[s1Weapon.Key];
                var s2Reload = s2.WorldState.WeaponReloading[s1Weapon.Key];
                if (s1Reload != s2Reload)
                    return false;
            }
            if (entity1.Aiming != entity2.Aiming)
                return false;
            if (entity1.ChangeWeapon != entity2.ChangeWeapon)
                return false;
            return true;
        }


    Vergleichsoperatoren für bestimmte Komponenten werden zusammen mit der gesamten EC-Struktur generiert, die speziell vom Codegenerator geschrieben wurde. Zum Beispiel gebe ich den generierten Code der Transformationskomponente des Vergleichsoperators an:

    Code
    public static bool operator ==(Transform a, Transform b)
    {
        if ((object)a == null && (object)b == null)
            return true;
        if ((object)a == null && (object)b != null)
            return false;
        if ((object)a != null && (object)b == null)
            return false;
        if (Math.Abs(a.Angle - b.Angle) > 0.01f)
            return false;
        if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f)
            return false;
        return true;
    }


    Es ist zu beachten, dass die Float-Werte mit einem ziemlich hohen Fehler verglichen werden. Dies geschieht, um die Anzahl der Desynchronisationen zwischen dem Client und dem Server zu verringern. Für den Player ist ein solcher Fehler zwar unsichtbar, spart jedoch erheblich Rechenressourcen des Systems.

    Die Komplexität des Abstimmungsmechanismus besteht darin, dass im Falle einer Fehlausrichtung des Client- und des Server-Zustands (Fehleinschätzung) alle vorhergesagten Client-Zustände, die vom Server noch nicht bestätigt wurden, bis zum aktuellen Tick in einem Frame erneut simuliert werden müssen. Abhängig vom Ping des Spielers können dies 5 bis 20 Ticks der Simulation sein. Wir mussten den Simulationscode deutlich optimieren, um in den Zeitrahmen zu investieren: 30 fps.

    Um den Abstimmungsprozess auf dem Client durchzuführen, müssen Sie zwei Arten von Daten speichern:

    1. Die Geschichte der vorhergesagten Zustände des Spielers.
    2. Und die Geschichte der Eingabe.

    Zu diesem Zweck verwenden wir einen zyklischen Puffer. Die Puffergröße beträgt 32 Ticks. Das ergibt bei einer Frequenz von 30 Hz ungefähr 1 Sekunde Echtzeit. Der Client kann problemlos mit dem Vorhersagemechanismus weiterarbeiten, ohne neue Daten vom Server zu erhalten, bis dieser Puffer gefüllt ist. Wenn die Differenz zwischen der Zeit des Clients und des Servers mehr als eine Sekunde beträgt, wird die Verbindung des Clients gewaltsam getrennt, und es wird versucht, die Verbindung wiederherzustellen. Wir haben eine solche Puffergröße aufgrund der Kosten des Versöhnungsprozesses im Falle einer Divergenz der Staaten der Welt. Beträgt der Unterschied zwischen Client und Server jedoch mehr als eine Sekunde, ist es billiger, eine vollständige Neuverbindung zum Server durchzuführen.

    Verringerte Verzögerungszeit


    Das obige Diagramm zeigt, dass das Datenübertragungsschema im Spiel zwei Puffer enthält:

    • Eingabepuffer auf dem Server;
    • World State Buffer auf dem Client.

    Der Zweck dieser Puffer ist derselbe - um Netzwerksprünge (Jitter) zu kompensieren. Tatsache ist, dass die Übertragung von Paketen über das Netzwerk ungleichmäßig ist. Und da die Netzwerk-Engine mit einer festen Frequenz von 30 Hz arbeitet, müssen die Daten am Eingang der Engine mit derselben Frequenz eingespeist werden. Wir können nicht einige ms warten, bis das nächste Paket den Empfänger erreicht. Wir verwenden Puffer für Eingabedaten und Zustände der Welt, um Zeit zum Ausgleich von Jitter zu haben. Wir verwenden den Gamestate-Puffer auch zur Interpolation, wenn eines der Pakete verloren geht.

    Zu Beginn des Spiels beginnt der Client erst dann mit der Synchronisierung mit dem Server, wenn er vom Server mehrere Zustände der Welt empfängt und der Gamestate-Puffer voll ist. Normalerweise beträgt die Größe dieses Puffers 3 Ticks (100 ms).

    Zur gleichen Zeit, wenn der Client mit dem Server synchronisiert wird, wird er um die Menge des Eingabepuffers für den Server vor der Serverzeit ausgeführt. Dh Der Client kontrolliert, wie weit der Server voraus ist. Die Startgröße des Eingangspuffers beträgt ebenfalls 3 Ticks (100 ms).

    Zu Beginn haben wir die Größe dieser Puffer als Konstanten implementiert. Dh Unabhängig davon, ob es im Netzwerk einen echten Jitter gab oder nicht, gab es eine feste Verzögerung von 200 ms (Eingabepuffergröße + Spielstatuspuffergröße) für die Aktualisierung der Daten. Addieren wir dazu den geschätzten durchschnittlichen Ping auf mobilen Geräten irgendwo in 200 ms, dann verbleibt die tatsächliche Verzögerung zwischen der Anwendung der Eingabe auf dem Client und der Bestätigung der Anwendung vom Server bei 400 ms!

    Es hat uns nicht gepasst.

    Tatsache ist, dass einige Systeme nur auf dem Server ausgeführt werden - wie zum Beispiel die Berechnung des HP-Players. Mit einer solchen Verzögerung macht der Spieler einen Schuss und erst nach 400 ms sieht er, wie der Gegner tötet. Wenn dies unterwegs geschah, gelang es dem Spieler normalerweise, über die Mauer oder ins Tierheim zu rennen und dort bereits zu sterben. Spieltests innerhalb des Teams haben gezeigt, dass eine solche Verzögerung das gesamte Gameplay komplett unterbricht.

    Die Lösung für dieses Problem war die Implementierung dynamischer Größen von Eingabepuffern und Gamestates:
    • Für den Gamestate-Puffer kennt der Client immer den aktuellen Pufferinhalt. Bei der Berechnung des nächsten Ticks prüft der Client, wie viele Steits sich bereits im Puffer befinden.
    • für den Eingabepuffer - Der Server begann zusätzlich zum Gamestate, den Wert des aktuellen Eingabepuffers für den bestimmten Client an den Client zu senden. Der Kunde wiederum analysiert diese beiden Werte.

    Der Algorithmus zum Ändern der Größe des Gamestate-Puffers lautet wie folgt:

    1. Der Client berücksichtigt die durchschnittliche Größe des Puffers über einen Zeitraum und Varianz.
    2. Wenn die Varianz innerhalb des normalen Bereichs liegt (d. H., Es hat während eines bestimmten Zeitraums keine großen Sprünge beim Füllen und Lesen aus dem Puffer gegeben), prüft der Client den Wert der durchschnittlichen Puffergröße für diesen Zeitraum.
    3. Wenn die durchschnittliche Pufferfüllung größer als die obere Randbedingung war (d. H. Der Puffer würde mehr als erforderlich gefüllt sein), "reduziert" der Client die Puffergröße durch Beenden eines zusätzlichen Simulationsticks.
    4. Wenn die durchschnittliche Pufferfüllung geringer war als die untere Randbedingung (d. H. Der Puffer hatte keine Zeit zum Füllen, bevor der Client mit dem Lesen davon begann), erhöht der Client in diesem Fall die Puffergröße durch Überspringen eines Simulationsticks.
    5. In dem Fall, in dem die Varianz über der Norm lag, können wir uns nicht auf diese Daten verlassen, weil Netzwerksprünge über einen bestimmten Zeitraum waren zu groß. Dann verwirft der Client alle aktuellen Daten und beginnt erneut mit der Erfassung von Statistiken.

    Verzögerungskompensation auf dem Server


    Aufgrund der Tatsache, dass der Client zeitverzögert Weltaktualisierungen vom Server erhält, sieht der Spieler die Welt ein wenig anders als auf dem Server. Der Spieler sieht sich in der Gegenwart und den Rest der Welt - in der Vergangenheit. Auf dem Server existiert die ganze Welt zur selben Zeit.


    Aus diesem Grund gibt es eine Situation, in der der Spieler lokal auf ein Ziel schießt, das sich auf einem Server an einem anderen Ort befindet.

    Um die Verzögerung zu kompensieren, verwenden wir Zeitrücklauf auf dem Server. Der Algorithmus funktioniert folgendermaßen:

    1. Der Client sendet mit jeder Eingabe zusätzlich die Zeit des Ticks an den Server, in der er den Rest der Welt sieht.
    2. Der Server überprüft diese Uhrzeit: Liegt der Unterschied zwischen der aktuellen Uhrzeit und der sichtbaren Uhrzeit der Client-Welt im Vertrauensbereich?
    3. Wenn die Zeit gültig ist, verlässt der Server den Spieler in der aktuellen Zeit und der Rest der Welt rollt in die Vergangenheit in den Zustand zurück, den der Spieler gesehen hat, und berechnet das Ergebnis des Schusses.
    4. Wenn der Spieler getroffen wird, wird der Schaden in der aktuellen Serverzeit angewendet.

    Das Zurückspulen der Zeit auf dem Server funktioniert wie folgt: Die Geschichte der Welt (in ECS) und die Geschichte der Physik (unterstützt von der Volatile Physics- Engine ) werden im Norden gespeichert . Zum Zeitpunkt der Fehlberechnung eines Schusses werden Spielerdaten aus dem aktuellen Stand der Welt und den übrigen Spielern - aus der Geschichte - entnommen.

    Der Code des Schussvalidierungssystems sieht folgendermaßen aus:
    public void Execute(GameState gs)
    {
        foreach (var shotPair in gs.WorldState.Shot)
        {
            var shot = shotPair.Value;
            var shooter = gs.WorldState[shotPair.Key];
            var shooterTransform = shooter.Transform;
            var weaponStats = gs.WorldState.WeaponStats[shot.WeaponId];
            // DeltaTime shouldn't exceed physics history size
            var shootDeltaTime = (int) (gs.Time - shot.ShotPlayerWorldTime);
            if (shootDeltaTime > PhysicsWorld.HistoryLength)
            {
                continue;
            }
            // Get the world at the time of shooting.
            var oldState = _immutableHistory.Get(shot.ShotPlayerWorldTime);
            var potentialTarget = oldState.WorldState[shot.Target.Id];
            var hitTargetId = _singleShotValidator.ValidateTargetAvailabilityInLine(oldState, potentialTarget, shooter,
                shootDeltaTime, weaponStats.ShotDistance, shooter.Transform.Angle.GetDirection());
            if (hitTargetId != 0)
            {    
                gs.WorldState.CreateEntity().AddDamage(gs.WorldState[hitTargetId], shooter, weaponStats.ShotDamage);
            }
        }
    }


    Ein wesentlicher Fehler des Ansatzes besteht darin, dass wir dem Kunden die Daten zum Zeitpunkt des Ticks anvertrauen, den er sieht. Potenziell kann ein Spieler einen Vorteil erzielen, indem er den Ping-Wert künstlich erhöht. Weil Je mehr ein Spieler einen Ping hat, desto weiter in der Vergangenheit macht er einen Schuss.

    Einige Probleme sind aufgetreten


    Während der Implementierung dieser Netzwerk-Engine sind wir mit vielen Problemen konfrontiert, von denen einige einen eigenen Artikel wert sind. Hier werde ich jedoch nur auf einige eingehen.

    Simulation der ganzen Welt im System der Vorhersage und des Kopierens


    Anfänglich hatten alle Systeme in unserem ECS nur eine Methode: void Execute (GameState gs). Bei dieser Methode wurden normalerweise Komponenten für alle Player verarbeitet.

    Ein Beispiel für ein Bewegungssystem in der ersten Implementierung:
    public sealed class MovementSystem : ISystem
    {
      public void Execute(GameState gs)
      {
        foreach (var movementPair in gs.WorldState.Movement)
        {
          var transform = gs.WorldState.Transform[movementPair.Key];
          transform.Position += movementPair.Value.Velocity * GameState.TickDuration;
        }
      }
    }


    In der lokalen Spielervorhersage mussten wir jedoch nur Komponenten verarbeiten, die sich auf einen bestimmten Spieler beziehen. Zunächst haben wir dies durch Kopieren umgesetzt.

    Der Vorhersageprozess war wie folgt:

    1. Erstellt eine Kopie des Gamestats.
    2. Eine Kopie wurde an den ECS-Eingang gesendet.
    3. Bestanden die Simulation der ganzen Welt in ECS.
    4. Aus dem neu erhaltenen Gamestate wurden alle Daten des lokalen Spielers kopiert.

    Die Vorhersagemethode sah folgendermaßen aus:
    void PredictNewState(GameState state)
    {
      var newState = _stateHistory.Get(state.Tick+1);
      var input = _inputHistory.Get(state.Tick);
      newState.Copy(state);
      _tempGameState.Copy(state);
      _ecsExecutor.Execute(_tempGameState, input);
      _playerEntitiesCopier.Copy(_tempGameState, newState);
    }


    Bei dieser Implementierung gab es zwei Probleme:

    1. Weil Wir verwenden Klassen, keine Strukturen - das Kopieren ist für uns eine ziemlich teure Operation (ca. 0,1-0,15 ms auf dem iPhone 5S).
    2. Die Simulation der ganzen Welt nimmt ebenfalls viel Zeit in Anspruch (ca. 1,5-2 ms auf dem iPhone 5S).

    Wenn wir bedenken, dass es während des Koordinierungsprozesses notwendig ist, von 5 bis 15 Staaten der Welt in einem Rahmen zu erzählen, dann war bei einer solchen Erkenntnis alles furchtbar langsam.

    Die Lösung war ganz einfach: zu lernen, wie man vortäuscht, die Welt in Teilen zu spielen, nämlich nur einen bestimmten Spieler vorzutäuschen. Wir haben alle Systeme neu geschrieben, damit Sie die Spieler-ID übertragen und nur diese simulieren können.

    Ein Beispiel für ein Bewegungssystem nach einer Änderung:
    public sealed class MovementSystem : ISystem
    {
      public void Execute(GameState gs)
      {
        foreach (var movementPair in gs.WorldState.Movement)
        {
            Move(gs.WorldState.Transform[movementPair.Key], movementPair.Value);
        }
      }
      public void ExecutePlayer(GameState gs, uint playerId)
      {
        var movement = gs.WorldState.Movement[playerId];
        if(movement != null)
        {
            Move(gs.WorldState.Transform[playerId], movement);
        }
      }
      private void Move(Transform transform, Movement movement)
      {
        transform.Position += movement.Velocity * GameState.TickDuration;
      }
    }


    Nach den Änderungen konnten wir unnötige Kopien im Vorhersagesystem entfernen und die Belastung des Matching-Systems verringern.

    Code:
    void PredictNewState(GameState state, uint playerId)
    {
      var newState = _stateHistory.Get(state.Tick+1);
      var input = _inputHistory.Get(state.Tick);
      newState.Copy(state);
      _ecsExecutor.Execute(newState, input, playerId);
    }


    Entitäten im Vorhersagesystem erstellen und löschen


    In unserem System basiert eine Entitätszuordnung auf dem Server und dem Client auf einer Ganzzahlkennung (ID). Für alle Entitäten verwenden wir eine durchgehende Nummerierung der Bezeichner, wobei jede neue Entität den Wert id = oldID + 1 hat.

    Dieser Ansatz ist bei der Implementierung sehr praktisch, hat jedoch einen großen Nachteil: Die Reihenfolge der Erstellung neuer Entitäten auf dem Client und dem Server kann unterschiedlich sein, und infolgedessen unterscheiden sich die Bezeichner der Entitäten.

    Dieses Problem zeigte sich in uns, als wir das Vorhersage-System des Spielers für Schüsse implementierten. Jeder Schuss, den wir haben, ist eine separate Einheit mit einer Schusskomponente. Für jeden Kunden waren die Entity-ID-Aufnahmen im Vorhersagesystem konsistent. Aber wenn im selben Moment ein anderer Spieler schoss, dann war auf dem Server die ID aller Schüsse anders als auf dem Client.

    Die Schüsse auf dem Server wurden in einer anderen Reihenfolge erstellt:



    Für Schüsse haben wir diese Einschränkung umgangen, basierend auf den Gameplay-Funktionen des Spiels. Shots sind schnelllebige Objekte, die innerhalb von Sekundenbruchteilen nach ihrer Erstellung im System zerstört werden. Auf dem Client haben wir einen separaten Bereich von IDs identifiziert, die sich nicht mit den Server-IDs überschneiden, und haben aufgehört, Aufnahmen in das übereinstimmende System zu übernehmen. Dh Lokale Spielerschüsse werden immer gemäß dem Vorhersage-System im Spiel gezogen und berücksichtigen keine Daten vom Server.

    Bei diesem Ansatz sieht der Spieler die Artefakte nicht auf dem Bildschirm (Löschen, Neuerstellen, Zurücksetzen von Aufnahmen), und die Diskrepanzen mit dem Server sind geringfügig und wirken sich nicht auf das Gameplay im Allgemeinen aus.

    Diese Methode löste das Problem mit Aufnahmen, aber nicht das gesamte Problem der Erstellung von Entitäten auf dem gesamten Client. Wir arbeiten noch an möglichen Lösungen für den Abgleich der erstellten Objekte auf Client und Server.

    Es ist auch zu beachten, dass dieses Problem nur die Erstellung neuer Entitäten (mit neuen IDs) betrifft. Das Hinzufügen und Löschen von Komponenten zu bereits erstellten Entitäten ist problemlos möglich: Komponenten haben keine Bezeichner und jede Entität kann nur eine Komponente eines bestimmten Typs haben. Daher erstellen wir normalerweise Entitäten auf dem Server, und in den Vorhersagesystemen werden nur Komponenten hinzugefügt / entfernt.

    Abschließend möchte ich sagen, dass das Implementieren von Multiplayer nicht die einfachste und schnellste Aufgabe ist, aber es gibt ziemlich viele Informationen dazu.

    Was zu lesen