Création d'un jeu en réseau multijoueur d'égal à égal

Jouer à un jeu multijoueur est toujours amusant. Au lieu de battre des adversaires contrôlés par l'IA, le joueur doit faire face à des stratégies créées par un autre être humain. Ce tutoriel présente la mise en œuvre d'un jeu multijoueur sur le réseau en utilisant une approche peer-to-peer (P2P) ne faisant pas autorité..

Remarque: Bien que ce tutoriel ait été écrit avec AS3 et Flash, vous devriez pouvoir utiliser les mêmes techniques et concepts dans presque tous les environnements de développement de jeux. Vous devez avoir une compréhension de base de la communication en réseau.

Vous pouvez télécharger ou insérer le code final à partir du référentiel GitHub ou des fichiers source compressés. Si vous souhaitez trouver des ressources uniques pour votre propre jeu, consultez la sélection d'actifs de jeu sur Envato Market..


Aperçu du résultat final

Démo du réseau. Les contrôles: flèches ou WASD bouger, Espace tirer, B déployer une bombe.

Art de Tyran Graphics remasterisé, Iron Plague et Hard Vacuum de Daniel Cook (Lost Garden).


introduction

Un jeu multijoueur joué sur le réseau peut être mis en œuvre en utilisant plusieurs approches différentes, qui peuvent être classées en deux groupes: faisant autorité et ne fait pas autorité.

Dans le groupe faisant autorité, l’approche la plus commune est la architecture client-serveur, où une entité centrale (le serveur faisant autorité) contrôle l'ensemble du jeu. Chaque client connecté au serveur reçoit en permanence des données, créant localement une représentation de l'état du jeu. C'est un peu comme regarder la télévision.

Mise en œuvre faisant autorité à l'aide d'une architecture client-serveur.

Si un client effectue une action, telle que se déplacer d'un point à un autre, ces informations sont envoyées au serveur. Le serveur vérifie si les informations sont correctes, puis met à jour son état de jeu. Après cela, il propage les informations vers tous les clients afin qu'ils puissent mettre à jour leur état de jeu en conséquence..

Dans le groupe ne faisant pas autorité, il n'y a pas d'entité centrale et chaque pair (jeu) contrôle son état de jeu. Dans une approche d'égal à égal (P2P), un homologue envoie des données à tous les autres pairs et en reçoit des données, en supposant que les informations sont fiables et correctes (sans tricherie):

Mise en œuvre non autorisée à l'aide d'une architecture P2P.

Dans ce tutoriel, je présente la mise en œuvre d'un jeu multijoueur sur le réseau utilisant une approche P2P non autorisée. Le jeu est une arène de combat à mort où chaque joueur contrôle un navire capable de tirer et de larguer des bombes.

Je vais me concentrer sur la communication et la synchronisation des états homologues. Le jeu et le code de réseau sont abstraits autant que possible dans un but de simplification.

Pointe: l'approche autoritaire est mieux protégée contre la fraude, car le serveur contrôle entièrement l'état du jeu et peut ignorer tout message suspect, comme si une entité déclarait avoir déplacé 200 pixels alors qu'elle ne pouvait que se déplacer..

Définir un jeu ne faisant pas autorité

Un jeu multijoueur ne faisant pas autorité ne disposant d'aucune entité centrale pour contrôler l'état du jeu, chaque pair doit donc contrôler son propre état du jeu, en communiquant les modifications et les actions importantes aux autres. En conséquence, le joueur voit deux scénarios simultanément: son navire se déplaçant en fonction de son entrée et un simulation de tous les autres navires contrôlés par les adversaires:

Le vaisseau du joueur est contrôlé localement. Les navires adverses sont simulés sur la base d'une communication réseau.

Le mouvement et les actions du navire du joueur sont guidés par une entrée locale, de sorte que l'état du jeu du joueur est mis à jour presque instantanément. Pour le mouvement de tous les autres navires, le joueur doit recevoir un message réseau de chaque adversaire indiquant l'emplacement de leurs navires..

Ces messages prennent du temps à voyager sur le réseau d'un ordinateur à un autre, donc quand le joueur reçoit une information disant que le navire d'un adversaire est à (x, y), c'est probablement plus là - c'est pourquoi c'est une simulation:

Délai de communication causé par le réseau.

Pour que la simulation reste précise, chaque pair est responsable de la propagation seulement les informations sur son navire, pas les autres. Cela signifie que, si le jeu a quatre joueurs - disons UNE, B, C et - joueur UNE est le seul capable d'informer où navire UNE est, si elle a été touchée, si elle a tiré une balle ou largué une bombe, et ainsi de suite. Tous les autres joueurs recevront des messages de UNE informer de ses actions et ils vont réagir en conséquence, donc si Comme balle a C expédier, alors C diffusera un message l'informant qu'il a été détruit.

En conséquence, chaque joueur verra tous les autres navires (et leurs actions) en fonction des messages reçus. Dans un monde parfait, il n'y aurait pas de latence réseau, les messages allaient et venaient instantanément et la simulation était extrêmement précise..

Cependant, à mesure que la latence augmente, la simulation devient imprécise. Par exemple, joueur UNE tire et voit localement la balle frapper Ble navire, mais rien ne se passe; c'est parce que UNEla vue de B est retardé en raison d'un décalage du réseau. Quand B effectivement reçu UNELe message de balle, B était à une position différente, donc aucun coup n'a été propagé.


Cartographie des actions pertinentes

L'identification des actions pertinentes est une étape importante dans la mise en œuvre du jeu et dans l'assurance que chaque joueur sera en mesure de voir la même simulation avec précision. Ces actions changent l’état actuel du jeu, comme se déplacer d’un point à un autre, larguer une bombe, etc..

Dans notre jeu, les actions les plus importantes sont:

  • tirer (Le navire du joueur a tiré une balle ou une bombe)
  • bouge toi (navire du joueur déplacé)
  • mourir (le navire du joueur a été détruit)
Actions du joueur pendant le jeu.

Chaque action doit être envoyée sur le réseau. Il est donc important de trouver un équilibre entre le nombre d’actions et la taille des messages réseau qu’elles vont générer. Plus le message est gros (c’est-à-dire que plus il contient de données), plus il faudra de temps pour le transporter, car il faudra peut-être plus d’un paquet réseau..

Les messages courts exigent moins de temps CPU pour emballer, envoyer et décompresser. De petits messages sur le réseau entraînent également l'envoi de plus de messages en même temps, ce qui augmente le débit.


Effectuer des actions indépendamment

Une fois les actions correspondantes mappées, il est temps de les rendre reproductibles sans intervention de l'utilisateur. Bien que ce soit un principe de bonne ingénierie logicielle, cela n’est peut-être pas évident du point de vue du jeu multijoueur.

En prenant comme exemple l'action de tir de notre jeu, si elle est intimement liée à la logique d'entrée, il n'est pas possible de réutiliser le même code de tir dans différentes situations:

Effectuer des actions indépendamment.

Lorsque le code de tir est découplé de la logique d'entrée, par exemple, il est possible d'utiliser le même code pour tirer sur les balles du joueur. et les balles de l'adversaire (quand un tel message réseau arrive). Il évite la réplication de code et évite beaucoup de maux de tête.

le Navire La classe dans notre jeu, par exemple, n'a pas de code multijoueur; il est complètement découplé. Il décrit un navire, que ce soit local ou non. La classe, cependant, a plusieurs méthodes pour manipuler le navire, telles que tourner() et un setter pour changer sa position. En conséquence, le code multijoueur peut faire pivoter un navire de la même manière que le code de saisie de l'utilisateur. La différence est que l'un est basé sur une entrée locale, tandis que l'autre est basé sur des messages réseau.


Échange de données basé sur des actions

Maintenant que toutes les actions pertinentes sont mappées, il est temps d'échanger des messages entre pairs pour créer la simulation. Avant d'échanger des données, un protocole de communication doit être formulé. En ce qui concerne les communications multijoueurs, un protocole peut être défini comme un ensemble de règles décrivant la structure d'un message, afin que tout le monde puisse envoyer, lire et comprendre ces messages..

Les messages échangés dans le jeu seront décrits comme des objets, contenant tous une propriété obligatoire appelée op (code d'opération). le op est utilisé pour identifier le type de message et indiquer les propriétés de l'objet de message. Voici la structure de tous les messages:

structure des messages réseau.
  • le OP_DIE le message indique qu'un navire a été détruit. Ses X et y propriétés contiennent l'emplacement du navire quand il a été détruit.
  • le OPPOSITION Le message contient l'emplacement actuel du navire d'un pair. Ses X et y propriétés contiennent les coordonnées du navire à l'écran, tandis que angle est l'angle de rotation actuel du navire.
  • le OP_SHOT Le message indique qu'un navire a tiré quelque chose (une balle ou une bombe). le X et y les propriétés contiennent l'emplacement du navire au moment du tir; la dx et mourir les propriétés indiquent la direction du navire, ce qui garantit que la balle sera répliquée dans tous les pairs en utilisant le même angle que le navire à tirer utilisé lorsqu'il visait; et le b propriété définit le type du projectile (balle ou bombe).

le Multijoueur Classe

Afin d’organiser le code multijoueur, nous créons un Multijoueur classe. Il est responsable de l'envoi et de la réception des messages, ainsi que de la mise à jour des navires locaux en fonction des messages reçus pour refléter l'état actuel de la simulation de jeu..

Sa structure initiale, contenant uniquement le code du message, est la suivante:

classe publique multijoueur const publique OP_SHOT: String = "S"; const public OP_DIE: String = "D"; const public OP_POSITION: String = "P"; fonction publique Multiplayer () // Le code de connexion a été omis.  fonction publique sendObject (obj: Object): void // Le code de réseau utilisé pour envoyer l'objet a été omis. 

Envoi de messages d'action

Pour chaque action pertinente mappée précédemment, un message réseau doit être envoyé afin que tous les pairs soient informés de cette action..

le OP_DIE l'action doit être envoyée lorsque le joueur est touché par une balle ou une explosion de bombe. Il existe déjà une méthode dans le code du jeu qui détruit le vaisseau du joueur quand il est touché, elle est donc mise à jour pour propager cette information:

fonction publique onPlayerHitByBullet (): void // navire du joueur Destoy playerShip.kill (); // MULTIJOUEUR: // Envoie un message à tous les autres joueurs // indiquant que le navire a été détruit. multiplayer.sendObject (op: Multiplayer.OP_DIE, x: platerShip.x, y: playerShip.y); 

le OPPOSITION l'action doit être envoyée chaque fois que le joueur change de position. Le code multijoueur est injecté dans le code du jeu pour propager cette information également:

fonction publique updatePlayerInput (): void var muté: Boolean = false; if (wasMoveKeysPressed ()) playerShip.x + = playerShip.direction.x; playerShip.y + = playerShip.direction.y; déplacé = vrai;  if (wasRotateKeysPressed ()) playerShip.rotate (10); déplacé = vrai;  // MULTIJOUEUR: // Si le joueur est déplacé (ou tourné), propage l'information. if (déplacé) multiplayer.sendObject (op: Multiplayer.OP_POSITION, x: playerShip.x, y: playerShip.y, angle: playerShip.angle); 

Finalement, le OP_SHOT l'action doit être envoyée chaque fois que le joueur tire quelque chose. Le message envoyé contient le type de puce qui a été déclenché, afin que chaque pair voie le projectile correct:

if (wasShootingKeysPressed ()) var bulletType: Class = getBulletType (); game.shoot (playerShip, bulletType); // MULTIJOUEUR: // Informe tous les autres joueurs que nous avons tiré un projectile. multiplayer.sendObject (op: Multiplayer.OP_SHOT, x: playerShip.x, y: playerShip.y, dx: playerShip.direction.x, dy: playerShip.direction.y, b: bBulletetype)); 

Synchronisation basée sur les données reçues

À ce stade, chaque joueur est capable de contrôler et de voir son navire. Sous le capot, les messages du réseau sont envoyés en fonction des actions pertinentes. La seule pièce manquante est l'addition des adversaires, afin que chaque joueur puisse voir les autres navires et interagir avec eux..

Dans le jeu, les navires sont organisés comme un tableau. Ce groupe n’avait jusqu’à présent qu’un seul navire (le joueur). Afin de créer la simulation pour tous les autres joueurs, le Multijoueur La classe sera modifiée pour ajouter un nouveau vaisseau à ce tableau chaque fois qu'un nouveau joueur rejoint l'arène:

classe publique multijoueur const publique OP_SHOT: String = "S"; const public OP_DIE: String = "D"; const public OP_POSITION: String = "P"; (…) // Cette méthode est invoquée chaque fois qu'un nouvel utilisateur rejoint l'arène. protected function handleUserAdded (user: UserObject): void // Crée une nouvelle base de navire à partir du nouvel identifiant de l'utilisateur. var ship: Ship = new ship (user.id); // Ajoute le vaisseau au tableau de vaisseaux déjà existants. game.ships.add (navire); 

Le code d’échange de messages fournit automatiquement un identifiant unique pour chaque joueur (le identifiant d'utilisateur dans le code ci-dessus). Le code multijoueur utilise cette identification pour créer un nouveau vaisseau lorsqu'un joueur entre dans l'arène. Ainsi, chaque navire a un identifiant unique. En utilisant l'identifiant d'auteur de chaque message reçu, il est possible de rechercher ce navire dans le tableau des navires..

Enfin, il est temps d'ajouter le handleGetObject () au Multijoueur classe. Cette méthode est invoquée chaque fois qu'un nouveau message arrive:

classe publique multijoueur const publique OP_SHOT: String = "S"; const public OP_DIE: String = "D"; const public OP_POSITION: String = "P"; (…) // Cette méthode est invoquée chaque fois qu'un nouvel utilisateur rejoint l'arène. protected function handleUserAdded (user: UserObject): void // Crée une nouvelle base de navire à partir du nouvel identifiant de l'utilisateur. var ship: Ship = new ship (user.id); // Ajoute le vaisseau au tableau de vaisseaux déjà existants. game.ships.add (navire);  fonction protégée handleGetObject (userId: String, data: Object): void var opCode: String = data.op; // Trouver le vaisseau du joueur qui a envoyé le message var ship: Ship = getShipById (userId); switch (opCode) case OP_POSITION: // Message pour mettre à jour la position du navire de l'auteur. ship.x = data.x; ship.y = data.y; ship.angle = data.angle; Pause; case OP_SHOT: // Message informant l'auteur du navire a tiré un projet. // Tout d'abord, mettez à jour la position et la direction du navire. ship.x = data.x; ship.y = data.y; ship.direction.x = data.dx; ship.direction.y = data.dy; // tire le projectile depuis l'emplacement du navire de l'auteur. game.shoot (ship, data.b); Pause; case OP_DIE: // Message informant que le navire de l'auteur a été détruit. ship.kill (); Pause; 

Lorsqu'un nouveau message arrive, le handleGetObject () La méthode est appelée avec deux paramètres: l'identifiant de l'auteur (identifiant unique) et les données du message. En analysant les données du message, le code d'opération est extrait et, en fonction de cela, toutes les autres propriétés sont également extraites..

À l'aide des données extraites, le code multijoueur reproduit toutes les actions reçues sur le réseau. Prenant le OP_SHOT message à titre d'exemple, voici les étapes effectuées pour mettre à jour l'état actuel du jeu:

  1. Recherchez le navire local identifié par identifiant d'utilisateur.
  2. Mettre à jour NavireLa position et l'angle des données en fonction des données reçues.
  3. Mettre à jour NavireDirection selon les données reçues.
  4. Invoquer la méthode de jeu responsable du tir de projectiles, d'une balle ou d'une bombe.

Comme décrit précédemment, le code de tir est découplé du joueur et de la logique de saisie, de sorte que le projectile tiré se comporte exactement comme celui qui a été tiré localement par le joueur..


Atténuer les problèmes de latence

Si le jeu déplace exclusivement des entités sur la base des mises à jour du réseau, tout message perdu ou retardé provoquera le "téléportage" de l'entité d'un point à un autre. Cela peut être atténué avec les prévisions locales.

Par exemple, en utilisant l’interpolation, le mouvement de l’entité est interpolé localement d’un point à un autre (tous deux reçus par les mises à jour du réseau). En conséquence, l'entité se déplacera sans problème entre ces points. Idéalement, le temps de latence ne devrait pas dépasser le temps qu’une entité met à interpoler d’un point à un autre..

Une autre astuce est l'extrapolation, qui déplace localement des entités en fonction de son état actuel. Cela suppose que l'entité ne changera pas son itinéraire actuel, il est donc prudent de la déplacer en fonction de sa direction et de sa vitesse actuelles, par exemple. Si le temps de latence n'est pas trop élevé, l'extrapolation reproduit avec précision le mouvement attendu de l'entité jusqu'à ce qu'une nouvelle mise à jour du réseau arrive, ce qui permet d'obtenir un mouvement fluide..

Malgré ces astuces, la latence du réseau peut parfois être extrêmement élevée et ingérable. La solution la plus simple à éliminer consiste à déconnecter les pairs problématiques. Une approche sûre pour cela consiste à utiliser un délai d'attente: si l'homologue prend plus qu'un temps spécifié pour répondre, il est déconnecté.


Conclusion

Faire une partie multijoueur sur le réseau est une tâche ardue et passionnante. Cela nécessite une manière différente de voir les choses puisque toutes les actions pertinentes doivent être envoyées et reproduites par tous les pairs. En conséquence, tous les joueurs voient une simulation de ce qui se passe, à l'exception du navire local, qui n'a pas de latence réseau..

Ce tutoriel décrit la mise en œuvre d'un jeu multijoueur utilisant une approche P2P non experte. Tous les concepts présentés peuvent être étendus pour implémenter différents mécanismes multijoueurs. Que le jeu multijoueur commence!