paint-brush
Unity Realtime Multiplayer, Parte 2: Protocolos TCP, UDP, WebSocketby@dmitrii
4,219
4,219

Unity Realtime Multiplayer, Parte 2: Protocolos TCP, UDP, WebSocket

A série Unity Networking Landscape in 2023 continua com foco em protocolos de transmissão de dados em jogos multijogador em tempo real. A Camada de Transporte do modelo OSI com TCP e UDP é explorada para otimizar a troca de dados entre os jogadores. O TCP garante a entrega confiável de dados, mas pode levar a atrasos, enquanto o UDP oferece transmissão mais rápida com possível perda de dados. WebSocket, um protocolo de nível de aplicativo baseado em TCP, permite comunicação bidirecional persistente e é adequado para jogos multijogador em tempo real. Exemplos de código para clientes e servidores TCP e UDP, bem como cliente e servidor WebSocket, ilustram as abordagens de implementação. A escolha do protocolo depende dos requisitos do jogo - TCP para confiabilidade, UDP para velocidade e WebSocket para comunicação bidirecional. O próximo artigo se aprofundará na organização da transmissão confiável de dados usando UDP.
featured image - Unity Realtime Multiplayer, Parte 2: Protocolos TCP, UDP, WebSocket
Dmitrii Ivashchenko HackerNoon profile picture
0-item
1-item


Minha série de artigos sobre o Unity Networking Landscape em 2023 continua! A postagem de hoje abordará os protocolos de transmissão de dados utilizados em jogos multijogador em tempo real.


Cumprimentos a todos! Sou Dmitrii Ivashchenko, engenheiro de software líder da MY.GAMES. Começaremos com uma breve visão geral de quais protocolos existem em diferentes níveis de interação de rede.


Visão geral do conteúdo

  • Níveis OSI
  • protocolo de Controle de Transmissão
    • Cliente TCP
    • Servidor TCP
  • Protocolo de datagrama do usuário
    • cliente UDP
    • Servidor UDP
  • Websocket
    • Cliente Websocket
    • Servidor Websocket
  • Conclusão


Níveis OSI

O modelo OSI (Open Systems Interconnection) é um modelo conceitual que caracteriza e padroniza as funções de um sistema de comunicação em termos de níveis de abstração. Este modelo foi desenvolvido pela Organização Internacional de Normalização (ISO) em 1978.

É composto por sete camadas:


  1. Camada Física : Esta camada lida com a transmissão e recepção de bits brutos no canal. Os protocolos nesta camada descrevem a interface física e as características do meio, incluindo representações de bits, taxas de transmissão, cabos físicos, cartões e designs de conectores.


  2. Camada de Enlace de Dados : Esta camada fornece transferência de dados através do meio físico e lida com erros que podem ocorrer no nível físico.


  3. Camada de Rede : Esta camada determina o caminho (roteamento) para transmissão de dados entre as redes.


  4. Camada de Transporte : Esta camada gerencia a entrega de mensagens entre os pontos e fornece confiabilidade, se necessário.


  5. Camada de Sessão : Essa camada gerencia o estabelecimento, a manutenção e o término da sessão entre usuários e aplicativos.


  6. Camada de apresentação : essa camada garante a independência dos dados das diferenças na representação de dados (codificação) nos lados do remetente e do destinatário.


  7. Camada de Aplicação : Esta camada inclui protocolos que têm relação direta com as aplicações com as quais o usuário interage.



Vale a pena notar que cada camada se baseia na anterior. As camadas acima da camada de transporte (sessão, apresentação e aplicativo) são altamente especializadas e não podem nos ajudar a organizar o multijogador em tempo real. Portanto, temos que parar na Camada de Transporte e usar seus protocolos TCP e UDP para otimizar a troca de dados entre os jogadores.


protocolo de Controle de Transmissão

O TCP é um protocolo orientado a conexão, o que significa que a comunicação ocorre entre dois dispositivos que estabelecem uma conexão para trocar dados. Este protocolo garante confiabilidade porque garante que todos os dados transmitidos chegarão ao seu destino na ordem correta. Se algum dado for perdido durante a transmissão, o TCP repetirá automaticamente a solicitação até que todos os dados sejam transmitidos com sucesso.


O estabelecimento de uma conexão envolve as seguintes etapas:


  • O cliente e o servidor criam soquetes para trocar dados por TCP.
  • O cliente envia um segmento SYN (sincronização) para o servidor com uma porta de destino especificada.
  • O servidor aceita o segmento SYN, cria seu próprio soquete e envia um segmento SYN-ACK (reconhecimento de sincronização) para o cliente.
  • O cliente aceita o segmento SYN-ACK e envia um segmento ACK (reconhecimento) ao servidor para concluir o processo de estabelecimento da conexão. Uma conexão bidirecional confiável está agora estabelecida.





Cliente TCP

O exemplo abaixo mostra uma implementação básica de um cliente TCP e pode ser estendido para trabalhar com dados específicos e lógica de jogo.


O código se conecta a um servidor com um endereço IP e porta especificados e, em seguida, envia e recebe dados por meio da conexão. Um fluxo de rede é usado para recepção de dados assíncronos do servidor.


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


O método ConnectToServer(string ipAddress, int port) estabelece uma conexão com o servidor no endereço IP e porta especificados. A recepção de dados do servidor é realizada no método ReceiveData(IAsyncResult result) , enquanto a transmissão de dados é feita no método SendData(string message) . Os dados recebidos são enviados para o console usando Debug.Log .


Servidor TCP

O exemplo de código abaixo representa um servidor TCP simples no Unity. O código inicializa o servidor, escuta a porta especificada e aceita conexões de entrada de clientes. Depois de se conectar a um cliente, o servidor envia e recebe dados por meio do fluxo de rede.


O método StartServer(int port) inicializa o servidor na porta especificada e começa a escutar conexões de entrada. Quando uma conexão do cliente é estabelecida, é executado o método HandleIncomingConnection(IAsyncResult result) , que recebe os dados do cliente e inicia uma operação assíncrona para receber os dados.


Os dados recebidos são processados no método ReceiveData(IAsyncResult result) . Depois de receber os dados do cliente, o servidor pode realizar o processamento necessário ou enviar os dados de volta ao cliente.


O método SendData(string message) envia dados para o cliente por meio do fluxo de rede. Os dados são convertidos em uma matriz de bytes e enviados ao cliente.


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


O método HandleIncomingConnection é usado para lidar com uma conexão de cliente de entrada. Após aceitar uma conexão, obtém um stream para troca de dados com o cliente e inicia uma operação assíncrona para receber os dados do cliente.


Em seguida, o método cria um buffer para recebimento de dados, com base no tamanho do buffer recebido do cliente conectado. Uma operação assíncrona é iniciada para ler dados do fluxo usando o buffer criado. Após a conclusão da operação de leitura, os dados serão passados para o método ReceiveData para processamento adicional.


Além disso, o método inicia outra operação assíncrona para aceitar conexões de entrada para a possibilidade de aceitar o próximo cliente.


Quando um cliente se conecta ao servidor, este método será chamado para lidar com a conexão e o servidor estará pronto para aceitar de forma assíncrona os próximos clientes.


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


O método ReceiveData é usado para processar os dados recebidos do cliente. Após a conclusão da operação de leitura de dados, o método verifica o número de bytes lidos. Se o número de bytes for menor ou igual a zero, isso significa que o cliente se desconectou. Nesse caso, o método fecha a conexão com o cliente e finaliza a execução.


Se o número de bytes lidos for maior que zero, o método cria um array de bytes para os dados recebidos e copia os dados lidos para este array. Em seguida, o método converte os bytes recebidos em uma string usando a codificação UTF-8 e envia a mensagem recebida para o 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); }


O método SendData é usado para enviar dados para o cliente. Ele converte a string de mensagem em uma matriz de bytes usando a codificação UTF-8 e grava essa matriz no fluxo de rede. Depois de enviar os dados, o método limpa o fluxo e envia a mensagem enviada para o console.

Embora a confiabilidade possa parecer uma grande vantagem, esse recurso TCP pode criar problemas em jogos multijogador em tempo real. A transmissão de dados no TCP pode ser retardada pelo mecanismo de retransmissão e controle de fluxo, o que pode levar a atrasos ou "lags".


Protocolo de datagrama do usuário

O UDP é um protocolo mais simples que não garante a entrega ou a ordem dos pacotes. Isso o torna muito mais rápido que o TCP, pois não gasta tempo estabelecendo uma conexão ou retransmitindo pacotes perdidos. Devido à sua velocidade e simplicidade, o UDP é frequentemente usado em jogos de rede e outras aplicações que requerem transmissão de dados em tempo real.



No entanto, usar o UDP exige que os desenvolvedores gerenciem a transmissão de dados com mais cuidado. Como o UDP não garante a entrega, pode ser necessário implementar seus próprios mecanismos para lidar com pacotes perdidos ou que chegaram fora de ordem.


Cliente UDP

Este código demonstra uma implementação básica de um cliente UDP no Unity. O método StartUDPClient inicializa o cliente UDP e o conecta ao servidor remoto especificado pelo endereço IP e porta. O cliente começa a receber dados de forma assíncrona usando o método BeginReceive e envia uma mensagem para o servidor usando o método 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); } }

Quando os dados são recebidos do servidor, o método ReceiveData é invocado, que processa os bytes recebidos e os converte em uma string. A mensagem recebida é então registrada no console. O cliente continua a receber dados de forma assíncrona chamando BeginReceive novamente.

O método SendData converte a mensagem em bytes e a envia para o servidor usando o método Send do cliente UDP.


Servidor UDP

Este código demonstra uma implementação básica de um servidor UDP no Unity. O método StartUDPServer inicializa o servidor UDP na porta especificada e começa a receber dados de forma assíncrona usando o método BeginReceive .


Quando os dados são recebidos de um cliente, o método ReceiveData é invocado, que processa os bytes recebidos e os converte em uma string. A mensagem recebida é então registrada no console. O servidor continua recebendo dados de forma assíncrona chamando BeginReceive novamente.


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


O método SendData recebe uma mensagem e um IPEndPoint representando o endereço e a porta do cliente. Ele converte a mensagem em bytes e a envia ao cliente usando o método Send do servidor UDP.


No contexto do desenvolvimento de jogos, a escolha entre TCP e UDP depende muito do tipo de jogo. Se o seu jogo requer entrega de dados confiável e o tempo de resposta não é um fator crítico (por exemplo, em estratégia em tempo real ou jogos baseados em turnos), o TCP pode ser uma escolha adequada. Por outro lado, se o seu jogo requer transmissão rápida de dados e pode lidar com alguma perda de dados (por exemplo, em jogos de tiro em primeira pessoa ou jogos de corrida), o UDP pode ser a melhor escolha.


WebSocket

WebSocket é um protocolo de comunicação que permite o estabelecimento de uma conexão persistente entre um navegador e um servidor. A principal diferença do HTTP regular é que ele permite a comunicação bidirecional; o servidor é capaz, não apenas de responder às solicitações do cliente, mas também de enviar mensagens para ele.


WebSocket é um protocolo de nível de aplicativo baseado em TCP. Ele suporta mensagens, não fluxos, o que o distingue do TCP regular. WebSocket inclui funcionalidade adicional que pode adicionar alguma sobrecarga de desempenho.


Veja como funciona, passo a passo:


  • O cliente envia uma solicitação HTTP especial chamada "solicitação de atualização". Essa solicitação informa ao servidor que o cliente deseja alternar para WebSocket.


  • Se o servidor suportar WebSocket e concordar em mudar, ele responderá com uma resposta HTTP especial que confirma a atualização para WebSocket.


  • Após a troca dessas mensagens, uma conexão bidirecional persistente é estabelecida entre o cliente e o servidor. Ambos os lados podem enviar mensagens a qualquer momento, não apenas em resposta a solicitações do outro lado.


  • Agora o cliente e o servidor podem enviar e receber mensagens a qualquer momento. Cada mensagem WebSocket é agrupada em "quadros" que indicam quando a mensagem começa e termina. Isso permite que o navegador e o servidor interpretem corretamente as mensagens, mesmo que cheguem em ordem mista ou sejam divididas em partes devido a problemas de rede.




Qualquer um dos lados pode fechar a conexão a qualquer momento, enviando um quadro especial de "fechar conexão". O outro lado pode responder com a confirmação do fechamento e, depois disso, ambos os lados devem parar imediatamente de enviar quaisquer outros dados. Os códigos de status também podem ser usados para indicar o motivo do fechamento.

Cliente WebSocket

O código abaixo fornece um exemplo de implementação de um cliente WebSocket no Unity usando a linguagem C# e a biblioteca WebSocketSharp .


No método Start() , que é chamado na inicialização do objeto, uma nova instância do WebSocket é criada, inicializada com o endereço do seu servidor WebSocket.


Depois disso, os manipuladores de eventos OnOpen e OnMessage são configurados.


 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 é acionado quando uma conexão é estabelecida com o servidor. Neste exemplo, quando a conexão é estabelecida, uma mensagem com o texto "Player1" é enviada ao servidor.


OnMessage é acionado quando uma mensagem é recebida do servidor. Aqui, ao receber uma mensagem, seu conteúdo é exibido no console.


Em seguida, o método ConnectAsync() é chamado, que se conecta de forma assíncrona ao servidor.


Servidor WebSocket

O código abaixo é um exemplo de criação de um servidor WebSocket.


No método Start() , que é chamado quando o objeto é inicializado, uma nova instância do WebSocketServer é criada, inicializada com o endereço do seu servidor WebSocket. Em seguida, o serviço AuthBehaviour WebSocket é adicionado ao servidor, disponível no caminho /Auth . Depois disso, o servidor é iniciado usando o método 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 é uma classe derivada de WebSocketBehavior que define o comportamento do servidor ao receber mensagens de clientes. Aqui, o método OnMessage() é substituído, que é chamado quando o servidor recebe uma mensagem de um cliente.


Nesse método, o texto da mensagem é primeiro extraído e, em seguida, uma mensagem é enviada ao console indicando qual cliente se conectou, usando o nome passado na mensagem. Em seguida, o servidor envia uma mensagem de volta ao cliente contendo informações sobre a conclusão da autenticação.


Conclusão

Discutimos como criar conexões para jogos Unity usando TCP, UDP e WebSockets, bem como as vantagens e desvantagens de cada um. Apesar da natureza leve do UDP e da confiabilidade do TCP, nenhum dos dois é uma boa escolha para organizar multijogador em tempo real em jogos. O UDP perderá alguns dados e o TCP não fornecerá a velocidade de transmissão necessária. No próximo artigo, discutiremos como organizar uma transmissão de dados confiável usando UDP.