Comme nous avons écrit le code réseau du tireur PvP mobile: synchronisation du joueur sur le client

Dans l'un des articles précédents , nous avons passé en revue les technologies utilisées dans notre nouveau projet - un jeu de tir rapide pour les appareils mobiles. Maintenant, je veux partager comment la partie client du code réseau du futur jeu est organisée, quelles difficultés nous avons rencontrées et comment les résoudre.




En général, les approches pour créer des jeux multijoueurs rapides au cours des 20 dernières années n'ont pas beaucoup changé. Plusieurs méthodes peuvent être distinguées dans l'architecture de code réseau:

  1. Mauvais calcul de l'état du monde sur le serveur, et affichage des résultats sur le client sans prédiction pour le joueur local et avec la possibilité de perdre l'entrée du joueur (entrée). Cette approche, soit dit en passant, est utilisée sur notre autre projet en développement - vous pouvez en lire plus ici .
  2. Lockstep
  3. Synchronisation de l'état du monde sans logique déterministe avec prédiction pour un acteur local.
  4. Synchronisation des entrées avec une logique et une prédiction entièrement déterministes pour un joueur local.

La particularité réside dans le fait que dans les tireurs, le plus important est la réactivité du contrôle - le joueur appuie sur un bouton (ou déplace le joystick) et veut voir immédiatement le résultat de son action. Tout d'abord, car l'état du monde dans de tels jeux change très rapidement et il est nécessaire de réagir immédiatement à la situation.

De ce fait, les approches sans le mécanisme de prédiction des actions des acteurs locaux (prédiction) n'étaient pas adaptées au projet, et nous avons opté pour une méthode de synchronisation de l'état du monde, sans logique déterministe.

Avantage de l'approche: moins de complexité de mise en œuvre par rapport à la méthode de synchronisation lors de l'échange d'entrée.
Moins: une augmentation du trafic lors de l'envoi de l'état du monde entier au client. Nous avons dû appliquer plusieurs techniques différentes d'optimisation du trafic pour que le jeu fonctionne de manière stable sur un réseau mobile.

Au cœur de l'architecture de gameplay, nous avons ECS, dont nous avons déjà parlé . Cette architecture vous permet de stocker facilement des données sur le monde du jeu, de les sérialiser, de les copier et de les transférer sur le réseau. Et aussi pour exécuter le même code à la fois sur le client et sur le serveur.

La simulation du monde du jeu se déroule à une fréquence fixe de 30 ticks par seconde. Cela vous permet de réduire le décalage de l'entrée du lecteur et de ne presque pas utiliser d'interpolation pour afficher visuellement l'état du monde. Mais il y a un inconvénient important à considérer lors du développement d'un tel système: pour que le système de prédiction du joueur local fonctionne correctement, le client doit simuler le monde avec la même fréquence que le serveur. Et nous avons passé beaucoup de temps à optimiser suffisamment la simulation pour les appareils cibles.

Mécanisme de prédiction d'action du joueur local (prédiction)


Le mécanisme de prédiction client est implémenté sur la base d'ECS en raison de l'exécution des mêmes systèmes sur le client et le serveur. Cependant, tous les systèmes ne sont pas exécutés sur le client, mais seulement ceux qui sont responsables du joueur local et ne nécessitent pas de données pertinentes sur les autres joueurs.

Exemple de listes de systèmes fonctionnant sur le client et le serveur:



À l'heure actuelle, nous avons environ 30 systèmes fonctionnant sur le client qui fournissent la prédiction du joueur et environ 80 systèmes fonctionnant sur le serveur. Mais nous ne prédisons pas des choses comme infliger des dégâts, utiliser des capacités ou soigner des alliés. Il y a deux problèmes dans ces mécanismes:

  1. Le client ne sait rien sur l'entrée d'autres joueurs et la prédiction de choses comme les dommages ou la guérison divergera presque toujours des données sur le serveur.
  2. La création locale de nouvelles entités (tirs, obus, capacités uniques) générées par un joueur pose le problème de l'appariement avec les entités créées sur le serveur.

Pour un tel mécanicien, le décalage se cache du joueur par d'autres moyens.

Exemple: nous tirons immédiatement l'effet de la frappe du tir et nous mettons à jour la vie de l'ennemi uniquement après avoir reçu la confirmation du coup du serveur.

Le schéma général du code réseau dans le projet




Le client et le serveur synchronisent l'heure par des numéros de tick. Étant donné que la transmission des données sur le réseau prend un certain temps, le client est toujours en avance sur le serveur de moitié RTT + la taille du tampon d'entrée sur le serveur. Le diagramme ci-dessus montre que le client envoie une entrée pour tick 20 (a). Dans le même temps, le tick 15 (b) est traité sur le serveur. Au moment où l'entrée du client atteint le serveur, le tick 20 sera traité sur le serveur.

L'ensemble du processus comprend les étapes suivantes: le client envoie l'entrée du lecteur au serveur (a) → cette entrée est traitée sur le serveur après HRTT + taille du tampon d'entrée (b) → le serveur envoie l'état mondial résultant au (x) client (s) → le client applique l'état mondial confirmé avec temps du serveur RTT + taille du tampon d'entrée + taille du tampon d'interpolation de l'état du jeu (d).

Une fois que le client a reçu un nouvel état du monde confirmé du serveur (d), il doit terminer le processus de réconciliation. Le fait est que le client effectue une prédiction mondiale basée uniquement sur l’entrée du joueur local. Les entrées des autres joueurs ne lui sont pas connues. Et lors du calcul de l'état du monde sur le serveur, le lecteur peut être dans un état différent, différent de ce que le client avait prédit. Cela peut se produire lorsqu'un joueur est assommé ou tué.

Le processus d'approbation se compose de deux parties:

  1. Comparaisons de l'état prévu du monde pour le tick N reçu du serveur. Seules les données relatives au joueur local sont impliquées dans la comparaison. Les données du reste du monde sont toujours extraites de l'état du serveur et ne participent pas à la coordination.
  2. Lors de la comparaison, deux cas peuvent se produire:

- si l'état prédit du monde coïncide avec celui confirmé par le serveur, le client, en utilisant les données prédites pour le joueur local et les nouvelles données pour le reste du monde, continue de simuler le monde en mode normal;
- si l'état prédit ne correspond pas, le client utilise tout l'état du serveur du monde et l'historique des entrées du client et raconte le nouvel état prédit du monde du joueur.

Dans le code, cela ressemble à ceci:
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; } 


La comparaison de deux états mondiaux se produit uniquement pour les données qui se rapportent au joueur local et participent au système de prédiction. Les données sont échantillonnées par ID de joueur.

Méthode de comparaison:
 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; } 


Des opérateurs de comparaison pour des composants spécifiques sont générés avec l'ensemble de la structure EC, spécialement écrits par un générateur de code. Pour un exemple, je vais donner le code généré de l'opérateur de comparaison de composants Transform:

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; } 


Il convient de noter que nos valeurs flottantes sont comparées à une erreur plutôt élevée. Ceci est fait afin de réduire la quantité de désynchronisation entre le client et le serveur. Pour le joueur, une telle erreur sera invisible, mais cela économise considérablement les ressources informatiques du système.

La complexité du mécanisme de coordination est qu'en cas de mauvaise synchronisation des états client et serveur (erreur de prédiction), il est nécessaire de simuler de manière répétée tous les états client prédits pour lesquels il n'y a pas de confirmation du serveur, jusqu'au tick actuel dans une seule trame. Selon le ping du joueur, cela peut aller de 5 à 20 ticks de simulation. Nous avons dû optimiser considérablement le code de simulation pour l'adapter à la période: 30 ips.

Pour terminer le processus d'approbation, deux types de données doivent être stockés sur le client:

  1. Une histoire des états de joueurs prévus.
  2. Et l'histoire de l'entrée.

À ces fins, nous utilisons un tampon circulaire. La taille du tampon est de 32 ticks. Cela à une fréquence de 30 Hz donne environ 1 seconde de temps réel. Le client peut continuer à travailler en toute sécurité sur le mécanisme de prédiction, sans recevoir de nouvelles données du serveur, jusqu'à remplir ce tampon. Si la différence entre l'heure du client et celle du serveur commence à être supérieure à une seconde, le client est obligé de se déconnecter en tentant de se reconnecter. Nous avons une telle taille de tampon en raison des coûts du processus de coordination en cas de divergence entre les États du monde. Mais si la différence entre le client et le serveur est supérieure à une seconde, il est moins coûteux d'effectuer une reconnexion complète au serveur.

Réduction du temps de latence


Le diagramme ci-dessus montre que dans le jeu, il existe deux tampons dans le schéma de transfert de données:

  • tampon d'entrée sur le serveur;
  • un tampon d'états du monde sur le client.

Le but de ces tampons est le même - pour compenser les sauts de réseau (gigue). Le fait est que la transmission de paquets sur le réseau est inégale. Et puisque le moteur de réseau fonctionne à une fréquence fixe de 30 Hz, les données doivent être fournies au moteur à la même fréquence. Nous n'avons pas la possibilité "d'attendre" quelques ms jusqu'à ce que le prochain paquet atteigne le destinataire. Nous utilisons des tampons pour les données d'entrée et les états du monde afin d'avoir une marge de temps pour la compensation de la gigue. Nous utilisons également le tampon gamestate pour l'interpolation si l'un des paquets est perdu.

Au début du jeu, le client ne démarre la synchronisation avec le serveur qu'après avoir reçu plusieurs états mondiaux du serveur et que la mémoire tampon gamestate est pleine. En règle générale, la taille de ce tampon est de 3 ticks (100 ms).

Dans le même temps, lorsque le client se synchronise avec le serveur, il «s'exécute» en avance sur l'heure du serveur de la valeur du tampon d'entrée sur le serveur. C'est-à-dire le client lui-même contrôle sa distance par rapport au serveur. La taille de départ du tampon d'entrée est également égale à 3 ticks (100 ms).

Initialement, nous avons implémenté la taille de ces tampons sous forme de constantes. C'est-à-dire que la gigue ait réellement existé sur le réseau ou non, il y avait un délai fixe de 200 ms (taille du tampon d'entrée + taille du tampon d'état du jeu) pour la mise à jour des données. Si nous ajoutons à cela le ping estimé moyen sur les appareils mobiles quelque part autour de 200 ms, alors le vrai délai entre l'utilisation de l'entrée sur le client et la confirmation de l'application depuis le serveur était de 400 ms!

Cela ne nous convenait pas.

Le fait est que certains systèmes ne fonctionnent que sur le serveur, comme par exemple le calcul des HP du joueur. Avec ce délai, le joueur tire et seulement après 400 ms voit comment il tue l'adversaire. Si cela se produisait en mouvement, le joueur réussissait généralement à courir derrière le mur ou à l'abri et y mourait déjà. Les tests de jeu au sein de l'équipe ont montré qu'un tel retard rompt complètement tout le gameplay.

La solution à ce problème a été l'implémentation de tailles dynamiques de tampons d'entrée et de gamestates:
  • pour un tampon gamestate, le client connaît toujours le contenu du tampon actuel. Au moment du calcul du prochain tick, le client vérifie combien d'états sont déjà dans le tampon;
  • pour le tampon d'entrée - le serveur, en plus de l'état du jeu, a commencé à envoyer au client la valeur du remplissage actuel du tampon d'entrée pour un client spécifique. Le client analyse à son tour ces deux valeurs.

L'algorithme de redimensionnement du tampon gamestate est approximativement le suivant:

  1. Le client considère la valeur moyenne de la taille du tampon sur une période de temps et de la variance.
  2. Si la variance se situe dans les limites normales (c'est-à-dire, pendant une période de temps donnée, il n'y a pas eu de sauts importants dans le remplissage et la lecture du tampon), le client vérifie la valeur de la taille moyenne du tampon pour cette période de temps.
  3. Si le remplissage moyen du tampon était supérieur à la condition aux limites supérieures (c'est-à-dire que le tampon serait rempli plus que nécessaire), le client «réduit» la taille du tampon en effectuant un tick de simulation supplémentaire.
  4. Si le remplissage moyen du tampon était inférieur à la condition aux limites inférieures (c'est-à-dire que le tampon n'a pas eu le temps de se remplir avant que le client ne commence à le lire) - dans ce cas, le client «augmente» la taille du tampon en sautant une coche de la simulation.
  5. Dans le cas où la variance était supérieure à la normale, nous ne pouvons pas nous fier à ces données, car les surtensions du réseau pendant une période donnée étaient trop importantes. Ensuite, le client supprime toutes les données actuelles et recommence à collecter des statistiques.

Compensation du décalage du serveur


En raison du fait que le client reçoit les mises à jour du monde du serveur avec un retard (retard), le joueur voit le monde un peu différent de celui qui existe sur le serveur. Le joueur se voit dans le présent et dans le reste du monde - dans le passé. Sur le serveur, le monde entier existe en une seule fois.


Pour cette raison, la situation est que le joueur tire localement sur une cible qui se trouve sur le serveur à un autre endroit.

Pour compenser le décalage, nous utilisons le rembobinage temporel sur le serveur. L'algorithme de fonctionnement est approximativement le suivant:

  1. Le client avec chaque entrée envoie en outre au serveur le temps de tick dans lequel il voit le reste du monde.
  2. Le serveur valide cette heure: est la différence entre l'heure actuelle et l'heure visible du monde du client dans l'intervalle de confiance.
  3. Si l'heure est valide, le serveur laisse le joueur à l'heure actuelle et le reste du monde revient à l'état que le joueur a vu et calcule le résultat du tir.
  4. Si un joueur frappe, les dégâts sont causés dans le temps du serveur actuel.

Le rembobinage du temps sur un serveur fonctionne comme suit: l'histoire du monde (en ECS) et l'histoire de la physique (supportée par le moteur de physique volatile ) sont stockées dans le nord. Au moment du calcul du tir, les données du joueur sont extraites de l'état actuel du monde et les autres joueurs de l'historique.

Le code du système de validation de prise de vue ressemble à ceci:
 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); } } } 


Un inconvénient majeur de l'approche est que nous faisons confiance au client dans les données sur l'heure de la tique qu'il voit. Potentiellement, un joueur peut gagner un avantage en augmentant artificiellement le ping. Parce que plus un joueur a de ping, plus il tire loin dans le passé.

Quelques problèmes rencontrés


Lors de la mise en œuvre de ce moteur de réseau, nous avons rencontré de nombreux problèmes, dont certains méritent un article séparé, mais ici je n'aborderai que certains d'entre eux.

Simulation du monde entier dans un système de prédiction et copie


Initialement, tous les systèmes de notre ECS n'avaient qu'une seule méthode: void Execute (GameState gs). Dans cette méthode, les composants liés à tous les joueurs étaient généralement traités.

Un exemple de système de mouvement dans l'implémentation initiale:
 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; } } } 


Mais dans le système de prédiction des joueurs locaux, nous n'avions besoin que de traiter les composants liés à un joueur spécifique. Initialement, nous l'avons implémenté à l'aide de la copie.

Le processus de prédiction était le suivant:

  1. Une copie de l'état du jeu a été créée.
  2. Une copie a été fournie à l'entrée ECS.
  3. Il y avait une simulation du monde entier dans ECS.
  4. Toutes les données relatives au joueur local ont été copiées à partir du nouvel état de jeu reçu.

La méthode de prédiction ressemblait à ceci:
 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); } 


Il y avait deux problèmes dans cette mise en œuvre:

  1. Parce que nous utilisons des classes, pas des structures - la copie est une opération assez coûteuse pour nous (environ 0,1-0,15 ms sur iPhone 5S).
  2. La simulation du monde entier prend également beaucoup de temps (environ 1,5 à 2 ms sur l'iPhone 5S).

Si nous tenons compte du fait qu'au cours du processus de coordination, il est nécessaire de recalculer de 5 à 15 États mondiaux dans un seul cadre, alors avec une telle mise en œuvre, tout a été terriblement lent.

La solution était assez simple: apprendre à simuler le monde en plusieurs parties, c'est-à-dire à ne simuler qu'un joueur spécifique. Nous avons réécrit tous les systèmes afin que vous puissiez transférer l'ID du joueur et simuler uniquement lui.

Un exemple de système de mouvement après un changement:
 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; } } 


Après les changements, nous avons pu nous débarrasser des copies inutiles dans le système de prédiction et réduire la charge sur le système correspondant.

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); } 


Création et suppression d'entités dans un système de prédiction


Dans notre système, la correspondance des entités sur le serveur et le client se produit par un identifiant entier (id). Pour toutes les entités, nous utilisons une numérotation de bout en bout des identifiants, chaque nouvelle entité a la valeur id = oldID + 1.

Cette approche est très pratique à mettre en œuvre, mais elle présente un inconvénient important: l'ordre de création de nouvelles entités sur le client et le serveur peut être différent et, par conséquent, les identifiants des entités seront différents.

Ce problème s'est manifesté lorsque nous avons implémenté un système de prédiction des tirs des joueurs. Chaque prise de vue avec nous est une entité distincte avec la composante de prise de vue. Pour chaque client, l'identifiant des entités de tir dans le système de prédiction était séquentiel. Mais si au même moment un autre joueur tirait, alors sur le serveur l'identifiant de tous les coups différait du client.

Les prises de vue sur le serveur ont été créées dans un ordre différent:



Pour les tirs, nous avons contourné cette limitation, basée sur les fonctionnalités de jeu du jeu. Les tirs sont des entités à vie rapide qui sont détruites dans le système une fraction de seconde après leur création. Sur le client, nous avons mis en évidence une plage distincte d'ID qui ne se croisent pas avec les ID de serveur et ne prennent plus en compte les plans dans le système de coordination. C'est-à-dire les coups des joueurs locaux sont toujours dessinés dans le jeu uniquement selon le système de prédiction et ne prennent pas en compte les données du serveur.

Avec cette approche, le joueur ne voit pas d'artefacts à l'écran (suppression, recréation, annulations de plans) et les écarts avec le serveur sont mineurs et n'affectent pas le gameplay dans son ensemble.

Cette méthode a permis de résoudre le problème des tirs, mais pas tout le problème de la création d'entités sur le client dans son ensemble. Nous travaillons toujours sur les méthodes possibles pour résoudre la comparaison des objets créés sur le client et le serveur.

Il convient également de noter que ce problème ne concerne que la création de nouvelles entités (avec de nouveaux identifiants). L'ajout et la suppression de composants sur des entités déjà créées se font sans problème: les composants n'ont pas d'identifiants et chaque entité ne peut avoir qu'un seul composant d'un type spécifique. Par conséquent, nous créons généralement des entités sur le serveur, et dans les systèmes de prédiction, nous ajoutons / supprimons uniquement des composants.

En conclusion, je tiens à dire que la tâche de mise en œuvre du multijoueur n'est pas la plus simple et la plus rapide, mais il y a beaucoup d'informations sur la façon de procéder.

Que lire


Source: https://habr.com/ru/post/fr415959/


All Articles