Ma série d'articles sur le paysage du réseau Unity en 2023 continue ! L'article d'aujourd'hui couvrira les protocoles de transmission de données utilisés dans les jeux multijoueurs en temps réel.
Salutations à tous ! Je suis Dmitrii Ivashchenko, ingénieur logiciel principal chez MY.GAMES. Nous commencerons par un bref aperçu des protocoles qui existent à différents niveaux d'interaction réseau.
Le modèle OSI (Open Systems Interconnection) est un modèle conceptuel qui caractérise et normalise les fonctions d'un système de communication en termes de niveaux d'abstraction. Ce modèle a été développé par l'Organisation internationale de normalisation (ISO) en 1978.
Il se compose de sept couches :
Couche physique : Cette couche traite de la transmission et de la réception des bits bruts sur le canal. Les protocoles de cette couche décrivent l'interface physique et les caractéristiques du support, y compris les représentations binaires, les débits de transmission, les câbles physiques, les cartes et les conceptions de connecteurs.
Couche de liaison de données : cette couche assure le transfert de données sur le support physique et gère les erreurs pouvant survenir au niveau physique.
Couche réseau : Cette couche détermine le chemin (routage) pour la transmission des données entre les réseaux.
Couche Transport : Cette couche gère la livraison des messages entre les points et assure la fiabilité si nécessaire.
Couche de session : cette couche gère l'établissement, la maintenance et l'arrêt des sessions entre les utilisateurs et les applications.
Couche de présentation : cette couche garantit l'indépendance des données vis-à-vis des différences de représentation des données (codage) côté émetteur et côté récepteur.
Couche d'application : cette couche comprend des protocoles qui ont une relation directe avec les applications avec lesquelles l'utilisateur interagit.
Il convient de noter que chaque couche s'appuie sur la précédente. Les couches au-dessus de la couche Transport (Session, Présentation et Application) sont hautement spécialisées et ne peuvent pas nous aider à organiser le multijoueur en temps réel. Il faut donc s'arrêter à la couche transport et utiliser ses protocoles TCP et UDP pour un échange de données optimal entre les joueurs.
TCP est un protocole orienté connexion, ce qui signifie que la communication se produit entre deux appareils qui établissent une connexion pour échanger des données. Ce protocole assure la fiabilité car il garantit que toutes les données transmises atteindront leur destination dans le bon ordre. Si des données sont perdues pendant la transmission, TCP réessayera automatiquement la demande jusqu'à ce que toutes les données soient transmises avec succès.
L'établissement d'une connexion implique les étapes suivantes :
L'exemple ci-dessous montre une implémentation de base d'un client TCP et peut être étendu pour fonctionner avec des données spécifiques et une logique de jeu.
Le code se connecte à un serveur avec une adresse IP et un port spécifiés, puis envoie et reçoit des données via la connexion. Un flux réseau est utilisé pour la réception de données asynchrones depuis le serveur.
using System; using System.Net; using System.Net.Sockets; using UnityEngine; public class TCPClient : MonoBehaviour { private TcpClient tcpClient; private NetworkStream networkStream; private byte[] receiveBuffer; private void Start() { // Example: Connect to server with IP address 127.0.0.1 (localhost) and port 5555 ConnectToServer("127.0.0.1", 5555); } private void ConnectToServer(string ipAddress, int port) { tcpClient = new TcpClient(); tcpClient.Connect(IPAddress.Parse(ipAddress), port); networkStream = tcpClient.GetStream(); // Start asynchronous operation to receive data from the server receiveBuffer = new byte[tcpClient.ReceiveBufferSize]; networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null); } private void ReceiveData(IAsyncResult result) { int bytesRead = networkStream.EndRead(result); byte[] receivedBytes = new byte[bytesRead]; Array.Copy(receiveBuffer, receivedBytes, bytesRead); string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes); Debug.Log("Received from server: " + receivedMessage); // Continue the asynchronous operation to receive data networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null); } private void SendData(string message) { byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message); networkStream.Write(sendBytes, 0, sendBytes.Length); networkStream.Flush(); } }
La méthode ConnectToServer(string ipAddress, int port)
établit une connexion au serveur à l'adresse IP et au port spécifiés. La réception des données depuis le serveur s'effectue dans la méthode ReceiveData(IAsyncResult result)
, tandis que la transmission des données s'effectue dans la méthode SendData(string message)
. Les données reçues sont envoyées à la console à l'aide Debug.Log
.
L'exemple de code ci-dessous représente un simple serveur TCP dans Unity. Le code initialise le serveur, écoute le port spécifié et accepte les connexions entrantes des clients. Après s'être connecté à un client, le serveur envoie et reçoit des données via le flux réseau.
La méthode StartServer(int port)
initialise le serveur sur le port spécifié et commence à écouter les connexions entrantes. Lorsqu'une connexion client est établie, la méthode HandleIncomingConnection(IAsyncResult result)
est exécutée, qui reçoit les données du client et démarre une opération asynchrone pour recevoir les données.
Les données reçues sont traitées dans la méthode ReceiveData(IAsyncResult result)
. Après avoir reçu les données du client, le serveur peut effectuer le traitement nécessaire ou renvoyer les données au client.
La méthode SendData(string message)
envoie des données au client via le flux réseau. Les données sont converties en un tableau d'octets et envoyées au client.
using System; using System.Net; using System.Net.Sockets; using UnityEngine; public class TCPServer : MonoBehaviour { private TcpListener tcpListener; private TcpClient connectedClient; private NetworkStream networkStream; private byte[] receiveBuffer; private void Start() { // Example: Start the server on port 5555 StartServer(5555); } private void StartServer(int port) { tcpListener = new TcpListener(IPAddress.Any, port); tcpListener.Start(); Debug.Log("Server started. Waiting for connections..."); // Start accepting client connections asynchronously tcpListener.BeginAcceptTcpClient(HandleIncomingConnection, null); } }
La méthode HandleIncomingConnection
est utilisée pour gérer une connexion client entrante. Après avoir accepté une connexion, il obtient un flux pour l'échange de données avec le client et lance une opération asynchrone pour recevoir des données du client.
Ensuite, le procédé crée une mémoire tampon pour recevoir des données, sur la base de la taille de la mémoire tampon reçue du client connecté. Une opération asynchrone est lancée pour lire les données du flux à l'aide du tampon créé. Une fois l'opération de lecture terminée, les données seront transmises à la méthode ReceiveData
pour un traitement ultérieur.
Aussi, le procédé lance une autre opération asynchrone pour accepter les connexions entrantes pour la possibilité d'accepter le client suivant.
Lorsqu'un client se connecte au serveur, cette méthode sera appelée pour gérer la connexion et le serveur sera prêt à accepter de manière asynchrone les clients suivants.
private void HandleIncomingConnection(IAsyncResult result) { connectedClient = tcpListener.EndAcceptTcpClient(result); networkStream = connectedClient.GetStream(); Debug.Log("Client connected: " + connectedClient.Client.RemoteEndPoint); // Start asynchronous operation to receive data from the client receiveBuffer = new byte[connectedClient.ReceiveBufferSize]; networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null); // Accept next client connection asynchronously tcpListener.BeginAcceptTcpClient(HandleIncomingConnection, null); }
La méthode ReceiveData
est utilisée pour traiter les données reçues du client. Une fois l'opération de lecture des données terminée, le procédé vérifie le nombre d'octets lus. Si le nombre d'octets est inférieur ou égal à zéro, cela signifie que le client s'est déconnecté. Dans ce cas, la méthode ferme la connexion avec le client et termine l'exécution.
Si le nombre d'octets lus est supérieur à zéro, le procédé crée un tableau d'octets pour les données reçues et copie les données lues dans ce tableau. Ensuite, la méthode convertit les octets reçus en une chaîne à l'aide du codage UTF-8 et envoie le message reçu à la console.
private void ReceiveData(IAsyncResult result) { int bytesRead = networkStream.EndRead(result); if (bytesRead <= 0) { Debug.Log("Client disconnected: " + connectedClient.Client.RemoteEndPoint); connectedClient.Close(); return; } byte[] receivedBytes = new byte[bytesRead]; Array.Copy(receiveBuffer, receivedBytes, bytesRead); string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes); Debug.Log("Received from client: " + receivedMessage); // Process received data // Continue the asynchronous operation to receive data networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null); } private void SendData(string message) { byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message); networkStream.Write(sendBytes, 0, sendBytes.Length); networkStream.Flush(); Debug.Log("Sent to client: " + message); }
La méthode SendData
est utilisée pour envoyer des données au client. Il convertit la chaîne de message en un tableau d'octets à l'aide du codage UTF-8 et écrit ce tableau dans le flux réseau. Après avoir envoyé les données, la méthode efface le flux et envoie le message envoyé à la console.
Bien que la fiabilité puisse sembler être un gros plus, cette fonctionnalité TCP peut créer des problèmes dans les jeux multijoueurs en temps réel. La transmission de données dans TCP peut être ralentie par le mécanisme de retransmission et de contrôle de flux, ce qui peut entraîner des retards ou des "décalages".
UDP est un protocole plus simple qui ne garantit pas la livraison ou l'ordre des paquets. Cela le rend beaucoup plus rapide que TCP, car il ne perd pas de temps à établir une connexion ou à retransmettre les paquets perdus. En raison de sa vitesse et de sa simplicité, UDP est souvent utilisé dans les jeux en réseau et d'autres applications nécessitant une transmission de données en temps réel.
Cependant, l'utilisation d'UDP oblige les développeurs à gérer plus soigneusement la transmission des données. Étant donné qu'UDP ne garantit pas la livraison, vous devrez peut-être implémenter vos propres mécanismes pour gérer les paquets perdus ou les paquets arrivés dans le désordre.
Ce code illustre une implémentation de base d'un client UDP dans Unity. La méthode StartUDPClient
initialise le client UDP et le connecte au serveur distant spécifié par l'adresse IP et le port. Le client commence à recevoir des données de manière asynchrone à l'aide de la méthode BeginReceive
et envoie un message au serveur à l'aide de la méthode SendData
.
using System; using System.Net; using System.Net.Sockets; using UnityEngine; public class UDPExample : MonoBehaviour { private UdpClient udpClient; private IPEndPoint remoteEndPoint; private void Start() { // Example: Start the UDP client and connect to the remote server StartUDPClient("127.0.0.1", 5555); } private void StartUDPClient(string ipAddress, int port) { udpClient = new UdpClient(); remoteEndPoint = new IPEndPoint(IPAddress.Parse(ipAddress), port); // Start receiving data asynchronously udpClient.BeginReceive(ReceiveData, null); // Send a message to the server SendData("Hello, server!"); } private void ReceiveData(IAsyncResult result) { byte[] receivedBytes = udpClient.EndReceive(result, ref remoteEndPoint); string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes); Debug.Log("Received from server: " + receivedMessage); // Continue receiving data asynchronously udpClient.BeginReceive(ReceiveData, null); } private void SendData(string message) { byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message); // Send the message to the server udpClient.Send(sendBytes, sendBytes.Length, remoteEndPoint); Debug.Log("Sent to server: " + message); } }
Lorsque des données sont reçues du serveur, la méthode ReceiveData
est appelée, qui traite les octets reçus et les convertit en une chaîne. Le message reçu est ensuite enregistré dans la console. Le client continue de recevoir des données de manière asynchrone en appelant à nouveau BeginReceive
.
La méthode SendData
convertit le message en octets et l'envoie au serveur à l'aide de la méthode Send
du client UDP.
Ce code illustre une implémentation de base d'un serveur UDP dans Unity. La méthode StartUDPServer
initialise le serveur UDP sur le port spécifié et commence à recevoir des données de manière asynchrone à l'aide de la méthode BeginReceive
.
Lorsque des données sont reçues d'un client, la méthode ReceiveData
est appelée, qui traite les octets reçus et les convertit en une chaîne. Le message reçu est ensuite enregistré dans la console. Le serveur continue de recevoir des données de manière asynchrone en appelant à nouveau BeginReceive
.
using System; using System.Net; using System.Net.Sockets; using UnityEngine; public class UDPServer : MonoBehaviour { private UdpClient udpServer; private IPEndPoint remoteEndPoint; private void Start() { // Example: Start the UDP server on port 5555 StartUDPServer(5555); } private void StartUDPServer(int port) { udpServer = new UdpClient(port); remoteEndPoint = new IPEndPoint(IPAddress.Any, port); Debug.Log("Server started. Waiting for messages..."); // Start receiving data asynchronously udpServer.BeginReceive(ReceiveData, null); } private void ReceiveData(IAsyncResult result) { byte[] receivedBytes = udpServer.EndReceive(result, ref remoteEndPoint); string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes); Debug.Log("Received from client: " + receivedMessage); // Process the received data // Continue receiving data asynchronously udpServer.BeginReceive(ReceiveData, null); } private void SendData(string message, IPEndPoint endPoint) { byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message); // Send the message to the client udpServer.Send(sendBytes, sendBytes.Length, endPoint); Debug.Log("Sent to client: " + message); } }
La méthode SendData
prend un message et un IPEndPoint
représentant l'adresse et le port du client. Il convertit le message en octets et l'envoie au client en utilisant la méthode Send
du serveur UDP.
Dans le cadre du développement de jeux, le choix entre TCP et UDP dépend en grande partie du type de votre jeu. Si votre jeu nécessite une livraison de données fiable et que le temps de réponse n'est pas un facteur critique (par exemple, dans les jeux de stratégie en temps réel ou au tour par tour), alors TCP peut être un choix approprié. D'autre part, si votre jeu nécessite une transmission de données rapide et peut gérer certaines pertes de données (par exemple, dans les jeux de tir à la première personne ou les jeux de course), alors UDP peut être le meilleur choix.
WebSocket est un protocole de communication qui permet l'établissement d'une connexion persistante entre un navigateur et un serveur. La principale différence avec le protocole HTTP classique est qu'il permet une communication bidirectionnelle ; le serveur est capable, non seulement de répondre aux requêtes des clients, il peut aussi lui envoyer des messages.
WebSocket est un protocole de niveau application basé sur TCP. Il prend en charge les messages, pas les flux, ce qui le distingue du TCP normal. WebSocket inclut des fonctionnalités supplémentaires qui peuvent augmenter les performances.
Voici comment cela fonctionne, étape par étape :
Le client envoie une requête HTTP spéciale appelée "Demande de mise à niveau". Cette requête informe le serveur que le client souhaite basculer vers WebSocket.
Si le serveur prend en charge WebSocket et accepte de basculer, il répond par une réponse HTTP spéciale qui confirme la mise à niveau vers WebSocket.
Après l'échange de ces messages, une connexion bidirectionnelle persistante est établie entre le client et le serveur. Les deux parties peuvent envoyer des messages à tout moment, pas seulement en réponse aux demandes de l'autre partie.
Désormais, le client et le serveur peuvent envoyer et recevoir des messages à tout moment. Chaque message WebSocket est enveloppé dans des "cadres" qui indiquent quand le message commence et se termine. Cela permet au navigateur et au serveur d'interpréter correctement les messages, même s'ils arrivent dans un ordre mélangé ou sont divisés en plusieurs parties en raison de problèmes de réseau.
L'un ou l'autre côté peut fermer la connexion à tout moment en envoyant une trame spéciale "fermer la connexion". L'autre partie peut répondre par une confirmation de la fermeture, et après cela, les deux parties doivent immédiatement cesser d'envoyer d'autres données. Les codes d'état peuvent également être utilisés pour indiquer la raison de la fermeture.
Le code ci-dessous fournit un exemple d'implémentation d'un client WebSocket dans Unity à l'aide du langage C# et de la bibliothèque WebSocketSharp .
Dans la méthode Start()
, qui est appelée lors de l'initialisation de l'objet, une nouvelle instance de WebSocket
est créée, initialisée avec l'adresse de votre serveur WebSocket.
Après cela, les gestionnaires d'événements OnOpen
et OnMessage
sont configurés.
using UnityEngine; using WebSocketSharp; public class WebSocketClient : MonoBehaviour { private WebSocket ws; void Start() { ws = new WebSocket("ws://your-websocket-server-url/Auth"); ws.OnOpen += OnOpenHandler; ws.OnMessage += OnMessageHandler; ws.ConnectAsync(); } private void OnOpenHandler(object sender, System.EventArgs e) { var data = "Player1"; ws.Send(data) } private void OnMessageHandler(object sender, MessageEventArgs e) { Debug.Log("WebSocket server said: " + e.Data); } }
OnOpen
est déclenché lorsqu'une connexion est établie avec le serveur. Dans cet exemple, lorsque la connexion est établie, un message avec le texte "Player1" est envoyé au serveur.
OnMessage
est déclenché lorsqu'un message est reçu du serveur. Ici, à la réception d'un message, son contenu est affiché dans la console.
Ensuite, la méthode ConnectAsync()
est appelée, qui se connecte de manière asynchrone au serveur.
Le code ci-dessous est un exemple de création d'un serveur WebSocket.
Dans la méthode Start()
, appelée lors de l'initialisation de l'objet, une nouvelle instance de WebSocketServer
est créée, initialisée avec l'adresse de votre serveur WebSocket. Ensuite, le service AuthBehaviour
WebSocket est ajouté au serveur, disponible au chemin /Auth
. Après cela, le serveur est démarré à l'aide de la méthode Start()
.
using UnityEngine; using WebSocketSharp; using WebSocketSharp.Server; public class WebSocketServer : MonoBehaviour { void Start() { var socket = new WebSocketServer("ws://your-websocket-server-url"); socket.AddWebSocketService<AuthBehaviour>("/Auth"); socket.Start(); } } public class AuthBehaviour : WebSocketBehavior { protected override void OnMessage (MessageEventArgs e) { var playerName = e.Data; Debug.Log("WebSocket client connected: " + playerName); Send("Auth Complete: " + playerName); } }
AuthBehaviour
est une classe dérivée de WebSocketBehavior
qui définit le comportement du serveur lors de la réception de messages de clients. Ici, la méthode OnMessage()
est remplacée, qui est appelée lorsque le serveur reçoit un message d'un client.
Dans cette méthode, le texte du message est d'abord extrait, puis un message est envoyé à la console indiquant quel client s'est connecté, en utilisant le nom passé dans le message. Ensuite, le serveur renvoie un message au client contenant des informations sur l'achèvement de l'authentification.
Nous avons expliqué comment créer des connexions pour les jeux Unity à l'aide de TCP, UDP et WebSockets, ainsi que les avantages et les inconvénients de chacun. Malgré la nature légère d'UDP et la fiabilité de TCP, aucun n'est un bon choix pour organiser le multijoueur en temps réel dans les jeux. UDP perdra certaines données et TCP ne fournira pas la vitesse de transmission nécessaire. Dans le prochain article, nous verrons comment organiser une transmission de données fiable à l'aide d'UDP.