¡Mi serie de artículos sobre el panorama de redes de Unity en 2023 continúa! La publicación de hoy cubrirá los protocolos de transmisión de datos utilizados en los juegos multijugador en tiempo real.
¡Saludos a todos! Soy Dmitrii Ivashchenko, ingeniero de software líder en MY.GAMES. Comenzaremos con una breve descripción general de los protocolos que existen en los diferentes niveles de interacción de la red.
El modelo OSI (Open Systems Interconnection) es un modelo conceptual que caracteriza y estandariza las funciones de un sistema de comunicación en términos de niveles de abstracción. Este modelo fue desarrollado por la Organización Internacional de Normalización (ISO) en 1978.
Consta de siete capas:
Capa física : esta capa se ocupa de la transmisión y recepción de bits sin procesar a través del canal. Los protocolos de esta capa describen la interfaz física y las características del medio, incluidas las representaciones de bits, las velocidades de transmisión, los cables físicos, las tarjetas y los diseños de los conectores.
Capa de enlace de datos : esta capa proporciona la transferencia de datos a través del medio físico y maneja los errores que pueden ocurrir en el nivel físico.
Capa de red : esta capa determina la ruta (enrutamiento) para la transmisión de datos entre redes.
Capa de transporte : esta capa gestiona la entrega de mensajes entre puntos y proporciona confiabilidad si es necesario.
Capa de sesión : esta capa administra el establecimiento, el mantenimiento y la finalización de la sesión entre los usuarios y las aplicaciones.
Capa de presentación : esta capa garantiza la independencia de los datos de las diferencias en la representación de datos (codificación) en los lados del remitente y el receptor.
Capa de aplicación : esta capa incluye protocolos que tienen una relación directa con las aplicaciones con las que interactúa el usuario.
Vale la pena señalar que cada capa se basa en la anterior. Las capas por encima de la capa de Transporte (Sesión, Presentación y Aplicación) son muy especializadas y no pueden ayudarnos a organizar el modo multijugador en tiempo real. Por lo tanto, debemos detenernos en la capa de transporte y usar sus protocolos TCP y UDP para un intercambio de datos óptimo entre los jugadores.
TCP es un protocolo orientado a la conexión, lo que significa que la comunicación se produce entre dos dispositivos que establecen una conexión para intercambiar datos. Este protocolo asegura la confiabilidad porque garantiza que todos los datos transmitidos llegarán a su destino en el orden correcto. Si se pierde algún dato durante la transmisión, TCP volverá a intentar automáticamente la solicitud hasta que todos los datos se transmitan con éxito.
El establecimiento de una conexión implica los siguientes pasos:
El siguiente ejemplo muestra una implementación básica de un cliente TCP y se puede ampliar para trabajar con datos específicos y lógica de juego.
El código se conecta a un servidor con una dirección IP y un puerto especificados, y luego envía y recibe datos a través de la conexión. Se utiliza un flujo de red para la recepción asíncrona de datos desde el 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(); } }
El método ConnectToServer(string ipAddress, int port)
establece una conexión con el servidor en la dirección IP y el puerto especificados. La recepción de datos desde el servidor se realiza con el método ReceiveData(IAsyncResult result)
, mientras que la transmisión de datos se realiza con el método SendData(string message)
. Los datos recibidos se envían a la consola mediante Debug.Log
.
El siguiente ejemplo de código representa un servidor TCP simple en Unity. El código inicializa el servidor, escucha el puerto especificado y acepta las conexiones entrantes de los clientes. Después de conectarse a un cliente, el servidor envía y recibe datos a través del flujo de red.
El método StartServer(int port)
inicializa el servidor en el puerto especificado y comienza a escuchar las conexiones entrantes. Cuando se establece una conexión de cliente, se ejecuta el método HandleIncomingConnection(IAsyncResult result)
, que recibe datos del cliente e inicia una operación asíncrona para recibir datos.
Los datos recibidos se procesan en el método ReceiveData(IAsyncResult result)
. Después de recibir los datos del cliente, el servidor puede realizar el procesamiento necesario o devolver los datos al cliente.
El método SendData(string message)
envía datos al cliente a través del flujo de red. Los datos se convierten en una matriz de bytes y se envían al 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); } }
El método HandleIncomingConnection
se usa para manejar una conexión de cliente entrante. Después de aceptar una conexión, obtiene un flujo para el intercambio de datos con el cliente e inicia una operación asíncrona para recibir datos del cliente.
Luego, el método crea un búfer para recibir datos, según el tamaño del búfer recibido del cliente conectado. Se inicia una operación asincrónica para leer datos de la secuencia utilizando el búfer creado. Una vez completada la operación de lectura, los datos se pasarán al método ReceiveData
para su posterior procesamiento.
Además, el método lanza otra operación asíncrona para aceptar conexiones entrantes para la posibilidad de aceptar el siguiente cliente.
Cuando un cliente se conecta al servidor, se llamará a este método para manejar la conexión y el servidor estará listo para aceptar de forma asíncrona a los siguientes 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); }
El método ReceiveData
se utiliza para procesar los datos recibidos del cliente. Una vez completada la operación de lectura de datos, el método comprueba el número de bytes leídos. Si el número de bytes es menor o igual a cero, significa que el cliente se ha desconectado. En este caso, el método cierra la conexión con el cliente y finaliza la ejecución.
Si el número de bytes leídos es mayor que cero, el método crea una matriz de bytes para los datos recibidos y copia los datos leídos en esta matriz. Luego, el método convierte los bytes recibidos en una cadena utilizando la codificación UTF-8 y envía el mensaje recibido a la consola.
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); }
El método SendData
se utiliza para enviar datos al cliente. Convierte la cadena del mensaje en una matriz de bytes utilizando la codificación UTF-8 y escribe esta matriz en el flujo de red. Después de enviar los datos, el método borra la transmisión y envía el mensaje enviado a la consola.
Aunque la confiabilidad puede parecer una gran ventaja, esta función de TCP puede crear problemas en los juegos de varios jugadores en tiempo real. La transmisión de datos en TCP puede ralentizarse por el mecanismo de retransmisión y control de flujo, lo que puede provocar retrasos o "retrasos".
UDP es un protocolo más simple que no garantiza la entrega ni el orden de los paquetes. Esto lo hace mucho más rápido que TCP, ya que no pierde tiempo estableciendo una conexión o retransmitiendo paquetes perdidos. Debido a su velocidad y simplicidad, UDP se usa a menudo en juegos en red y otras aplicaciones que requieren transmisión de datos en tiempo real.
Sin embargo, el uso de UDP requiere que los desarrolladores administren la transmisión de datos con más cuidado. Dado que UDP no garantiza la entrega, es posible que deba implementar sus propios mecanismos para manejar los paquetes perdidos o los paquetes que llegaron fuera de servicio.
Este código demuestra una implementación básica de un cliente UDP en Unity. El método StartUDPClient
inicializa el cliente UDP y lo conecta al servidor remoto especificado por la dirección IP y el puerto. El cliente comienza a recibir datos de forma asincrónica mediante el método BeginReceive
y envía un mensaje al servidor mediante el 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); } }
Cuando se reciben datos del servidor, se invoca el método ReceiveData
, que procesa los bytes recibidos y los convierte en una cadena. A continuación, el mensaje recibido se registra en la consola. El cliente continúa recibiendo datos de forma asincrónica llamando BeginReceive
nuevamente.
El método SendData
convierte el mensaje en bytes y lo envía al servidor utilizando el método Send
del cliente UDP.
Este código demuestra una implementación básica de un servidor UDP en Unity. El método StartUDPServer
inicializa el servidor UDP en el puerto especificado y comienza a recibir datos de forma asincrónica mediante el método BeginReceive
.
Cuando se reciben datos de un cliente, se invoca el método ReceiveData
, que procesa los bytes recibidos y los convierte en una cadena. A continuación, el mensaje recibido se registra en la consola. El servidor continúa recibiendo datos de forma asincrónica llamando BeginReceive
nuevamente.
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); } }
El método SendData
toma un mensaje y un IPEndPoint
que representa la dirección y el puerto del cliente. Convierte el mensaje en bytes y lo envía al cliente mediante el método Send
del servidor UDP.
En el contexto del desarrollo de juegos, la elección entre TCP y UDP depende en gran medida del tipo de juego. Si su juego requiere una entrega de datos confiable y el tiempo de respuesta no es un factor crítico (por ejemplo, en juegos de estrategia en tiempo real o por turnos), entonces TCP puede ser una opción adecuada. Por otro lado, si su juego requiere una transmisión de datos rápida y puede manejar alguna pérdida de datos (por ejemplo, en juegos de disparos en primera persona o juegos de carreras), UDP puede ser la mejor opción.
WebSocket es un protocolo de comunicación que permite establecer una conexión persistente entre un navegador y un servidor. La principal diferencia con el HTTP normal es que permite la comunicación bidireccional; el servidor no solo puede responder a las solicitudes de los clientes, sino que también puede enviarle mensajes.
WebSocket es un protocolo de nivel de aplicación basado en TCP. Admite mensajes, no flujos, lo que lo distingue del TCP normal. WebSocket incluye una funcionalidad adicional que puede agregar cierta sobrecarga de rendimiento.
Así es como funciona, paso a paso:
El cliente envía una solicitud HTTP especial llamada "Solicitud de actualización". Esta solicitud informa al servidor que el cliente desea cambiar a WebSocket.
Si el servidor es compatible con WebSocket y acepta cambiar, responde con una respuesta HTTP especial que confirma la actualización a WebSocket.
Después de intercambiar estos mensajes, se establece una conexión bidireccional persistente entre el cliente y el servidor. Ambos lados pueden enviar mensajes en cualquier momento, no solo en respuesta a solicitudes del otro lado.
Ahora el cliente y el servidor pueden enviar y recibir mensajes en cualquier momento. Cada mensaje de WebSocket está envuelto en "marcos" que indican cuándo comienza y termina el mensaje. Esto permite que el navegador y el servidor interpreten correctamente los mensajes, incluso si llegan en un orden mixto o se dividen en partes debido a problemas de red.
Cualquiera de los lados puede cerrar la conexión en cualquier momento enviando un marco especial de "conexión cerrada". El otro lado puede responder con una confirmación del cierre y, después de eso, ambos lados deben dejar de enviar inmediatamente cualquier otro dato. Los códigos de estado también se pueden utilizar para indicar el motivo del cierre.
El siguiente código proporciona un ejemplo de implementación de un cliente WebSocket en Unity mediante el lenguaje C# y la biblioteca WebSocketSharp .
En el método Start()
, que se invoca en la inicialización del objeto, se crea una nueva instancia de WebSocket
, inicializada con la dirección de su servidor WebSocket.
Después de eso, se configuran los controladores de eventos OnOpen
y OnMessage
.
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
se activa cuando se establece una conexión con el servidor. En este ejemplo, cuando se establece la conexión, se envía un mensaje con el texto "Player1" al servidor.
OnMessage
se activa cuando se recibe un mensaje del servidor. Aquí, al recibir un mensaje, su contenido se muestra en la consola.
Luego, se llama al método ConnectAsync()
, que se conecta de forma asíncrona al servidor.
El siguiente código es un ejemplo de creación de un servidor WebSocket.
En el método Start()
, que se llama cuando se inicializa el objeto, se crea una nueva instancia de WebSocketServer
, inicializada con la dirección de su servidor WebSocket. Luego, el servicio AuthBehaviour
WebSocket se agrega al servidor, disponible en la ruta /Auth
. Después de eso, el servidor se inicia utilizando el 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
es una clase derivada de WebSocketBehavior
que define el comportamiento del servidor cuando recibe mensajes de los clientes. Aquí, se anula el método OnMessage()
, que se llama cuando el servidor recibe un mensaje de un cliente.
En este método, primero se extrae el texto del mensaje, luego se envía un mensaje a la consola que indica qué cliente se ha conectado, usando el nombre pasado en el mensaje. Luego, el servidor envía un mensaje al cliente que contiene información sobre la finalización de la autenticación.
Hemos discutido cómo crear conexiones para juegos de Unity usando TCP, UDP y WebSockets, así como las ventajas y desventajas de cada uno. A pesar de la naturaleza liviana de UDP y la confiabilidad de TCP, ninguna es una buena opción para organizar juegos multijugador en tiempo real. UDP perderá algunos datos y TCP no proporcionará la velocidad de transmisión necesaria. En el próximo artículo, discutiremos cómo organizar una transmisión de datos confiable utilizando UDP.