Portar un juego multijugador de C++ a la web con Cheerp, WebRTC y Firebase

introducción

Nuestra empresa Tecnologías inclinadas proporciona soluciones para migrar aplicaciones de escritorio tradicionales a la web. Nuestro compilador de C++ animar genera una combinación de WebAssembly y JavaScript, que proporciona ambos interacción sencilla con el navegadory alto rendimiento.

Como ejemplo de su aplicación, decidimos trasladar un juego multijugador a la web y elegimos Teeworlds. Teeworlds es un juego retro XNUMXD multijugador con una pequeña pero activa comunidad de jugadores (¡incluyéndome a mí!). Es pequeño tanto en términos de recursos descargados como de requisitos de CPU y GPU: un candidato ideal.

Portar un juego multijugador de C++ a la web con Cheerp, WebRTC y Firebase
Ejecutando en el navegador de Teeworlds

Decidimos utilizar este proyecto para experimentar con Soluciones generales para portar código de red a la web.. Esto generalmente se hace de las siguientes maneras:

  • XMLHttpRequest/buscar, si la parte de la red consta únicamente de solicitudes HTTP, o
  • WebSockets.

Ambas soluciones requieren alojar un componente de servidor en el lado del servidor y ninguna permite su uso como protocolo de transporte. UDP. Esto es importante para aplicaciones en tiempo real, como software y juegos de videoconferencia, porque garantiza la entrega y el orden de los paquetes de protocolo. TCP puede convertirse en un obstáculo para la baja latencia.

Existe una tercera forma: utilizar la red desde el navegador: WebRTC.

Canal de datos RTC Admite transmisiones confiables y no confiables (en el último caso intenta usar UDP como protocolo de transporte siempre que sea posible) y puede usarse tanto con un servidor remoto como entre navegadores. Esto significa que podemos portar toda la aplicación al navegador, ¡incluido el componente del servidor!

Sin embargo, esto conlleva una dificultad adicional: antes de que dos pares WebRTC puedan comunicarse, necesitan realizar un protocolo de enlace relativamente complejo para conectarse, lo que requiere varias entidades de terceros (un servidor de señalización y uno o más servidores). ATURDIR/GIRO).

Idealmente, nos gustaría crear una API de red que utilice WebRTC internamente, pero que sea lo más parecida posible a una interfaz UDP Sockets que no necesite establecer una conexión.

Esto nos permitirá aprovechar WebRTC sin tener que exponer detalles complejos en el código de la aplicación (que queríamos cambiar lo menos posible en nuestro proyecto).

WebRTC mínimo

WebRTC es un conjunto de API disponibles en los navegadores que proporciona transmisión de igual a igual de audio, vídeo y datos arbitrarios.

La conexión entre pares se establece (incluso si hay NAT en uno o ambos lados) utilizando servidores STUN y/o TURN a través de un mecanismo llamado ICE. Los pares intercambian información ICE y parámetros de canal mediante oferta y respuesta del protocolo SDP.

¡Guau! ¿Cuántas abreviaturas a la vez? Expliquemos brevemente qué significan estos términos:

  • Utilidades transversales de sesión para NAT (ATURDIR) — un protocolo para evitar NAT y obtener un par (IP, puerto) para intercambiar datos directamente con el host. Si logra completar su tarea, sus compañeros podrán intercambiar datos de forma independiente entre sí.
  • Transversal usando relés alrededor de NAT (GIRO) También se utiliza para el cruce de NAT, pero lo implementa reenviando datos a través de un proxy que es visible para ambos pares. Agrega latencia y es más costoso de implementar que STUN (porque se aplica durante toda la sesión de comunicación), pero a veces es la única opción.
  • Establecimiento de conectividad interactiva (HIELO) se utiliza para seleccionar el mejor método posible para conectar dos pares en función de la información obtenida al conectar pares directamente, así como la información recibida por cualquier número de servidores STUN y TURN.
  • Protocolo de descripción de sesión (partido socialdemócrata) es un formato para describir los parámetros del canal de conexión, por ejemplo, candidatos ICE, códecs multimedia (en el caso de un canal de audio/video), etc... Uno de los pares envía una Oferta SDP y el segundo responde con una Respuesta SDP . . Después de esto, se crea un canal.

Para crear dicha conexión, los pares deben recopilar la información que reciben de los servidores STUN y TURN e intercambiarla entre sí.

El problema es que todavía no tienen la capacidad de comunicarse directamente, por lo que debe existir un mecanismo fuera de banda para intercambiar estos datos: un servidor de señalización.

Un servidor de señalización puede ser muy simple porque su único trabajo es reenviar datos entre pares en la fase de protocolo de enlace (como se muestra en el diagrama siguiente).

Portar un juego multijugador de C++ a la web con Cheerp, WebRTC y Firebase
Diagrama de secuencia de protocolo de enlace WebRTC simplificado

Descripción general del modelo de red Teeworlds

La arquitectura de red de Teeworlds es muy sencilla:

  • Los componentes del cliente y del servidor son dos programas diferentes.
  • Los clientes ingresan al juego conectándose a uno de varios servidores, cada uno de los cuales alberga solo un juego a la vez.
  • Toda la transferencia de datos del juego se realiza a través del servidor.
  • Se utiliza un servidor maestro especial para recopilar una lista de todos los servidores públicos que se muestran en el cliente del juego.

Gracias al uso de WebRTC para el intercambio de datos, podemos transferir el componente del servidor del juego al navegador donde se encuentra el cliente. Esto nos brinda una gran oportunidad...

Deshazte de los servidores

La falta de lógica de servidor tiene una gran ventaja: podemos implementar toda la aplicación como contenido estático en Github Pages o en nuestro propio hardware detrás de Cloudflare, garantizando así descargas rápidas y un alto tiempo de actividad de forma gratuita. De hecho, podemos olvidarnos de ellos, y si tenemos suerte y el juego se vuelve popular, no será necesario modernizar la infraestructura.

Sin embargo, para que el sistema funcione, todavía tenemos que utilizar una arquitectura externa:

  • Uno o más servidores STUN: Tenemos varias opciones gratuitas para elegir.
  • Al menos un servidor TURN: aquí no hay opciones gratuitas, por lo que podemos montar el nuestro o pagar por el servicio. Afortunadamente, la mayoría de las veces la conexión se puede establecer a través de servidores STUN (y proporcionar p2p verdadero), pero se necesita TURN como opción alternativa.
  • Servidor de señalización: A diferencia de los otros dos aspectos, la señalización no está estandarizada. De qué será realmente responsable el servidor de señalización depende en cierta medida de la aplicación. En nuestro caso, antes de establecer una conexión es necesario intercambiar una pequeña cantidad de datos.
  • Teeworlds Master Server: Es utilizado por otros servidores para anunciar su existencia y por clientes para encontrar servidores públicos. Si bien no es obligatorio (los clientes siempre pueden conectarse manualmente a un servidor que conocen), sería bueno tenerlo para que los jugadores puedan participar en juegos con personas aleatorias.

Decidimos utilizar los servidores STUN gratuitos de Google e implementamos un servidor TURN nosotros mismos.

Para los dos últimos puntos utilizamos Base de fuego:

  • El servidor maestro de Teeworlds se implementa de forma muy sencilla: como una lista de objetos que contienen información (nombre, IP, mapa, modo,...) de cada servidor activo. Los servidores publican y actualizan su propio objeto, y los clientes toman la lista completa y la muestran al reproductor. También mostramos la lista en la página de inicio como HTML para que los jugadores puedan simplemente hacer clic en el servidor y ser llevados directamente al juego.
  • La señalización está estrechamente relacionada con la implementación de nuestros sockets, que se describe en la siguiente sección.

Portar un juego multijugador de C++ a la web con Cheerp, WebRTC y Firebase
Lista de servidores dentro del juego y en la página de inicio.

Implementación de enchufes.

Queremos crear una API que sea lo más cercana posible a Posix UDP Sockets para minimizar la cantidad de cambios necesarios.

También queremos implementar el mínimo necesario para el intercambio de datos más sencillo a través de la red.

Por ejemplo, no necesitamos enrutamiento real: todos los pares están en la misma "LAN virtual" asociada con una instancia de base de datos de Firebase específica.

Por lo tanto, no necesitamos direcciones IP únicas: los valores de clave únicos de Firebase (similares a los nombres de dominio) son suficientes para identificar de forma única a los pares, y cada par asigna localmente direcciones IP "falsas" a cada clave que debe traducirse. Esto elimina por completo la necesidad de asignar una dirección IP global, que no es una tarea trivial.

Aquí está la API mínima que necesitamos implementar:

// Create and destroy a socket
int socket();
int close(int fd);
// Bind a socket to a port, and publish it on Firebase
int bind(int fd, AddrInfo* addr);
// Send a packet. This lazily create a WebRTC connection to the 
// peer when necessary
int sendto(int fd, uint8_t* buf, int len, const AddrInfo* addr);
// Receive the packets destined to this socket
int recvfrom(int fd, uint8_t* buf, int len, AddrInfo* addr);
// Be notified when new packets arrived
int recvCallback(Callback cb);
// Obtain a local ip address for this peer key
uint32_t resolve(client::String* key);
// Get the peer key for this ip
String* reverseResolve(uint32_t addr);
// Get the local peer key
String* local_key();
// Initialize the library with the given Firebase database and 
// WebRTc connection options
void init(client::FirebaseConfig* fb, client::RTCConfiguration* ice);

La API es simple y similar a la API de Posix Sockets, pero tiene algunas diferencias importantes: registrar devoluciones de llamadas, asignar IP locales y conexiones diferidas.

Registrar devoluciones de llamada

Incluso si el programa original utiliza E/S sin bloqueo, el código debe refactorizarse para ejecutarse en un navegador web.

La razón de esto es que el bucle de eventos en el navegador está oculto para el programa (ya sea JavaScript o WebAssembly).

En el entorno nativo podemos escribir código como este.

while(running) {
  select(...); // wait for I/O events
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
}

Si el bucle de eventos está oculto para nosotros, entonces debemos convertirlo en algo como esto:

auto cb = []() { // this will be called when new data is available
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
};
recvCallback(cb); // register the callback

Asignación de IP local

Los ID de nodo en nuestra "red" no son direcciones IP, sino claves de Firebase (son cadenas que se ven así: -LmEC50PYZLCiCP-vqde ).

Esto es conveniente porque no necesitamos un mecanismo para asignar IP y verificar su unicidad (ni tampoco eliminarlas después de que el cliente se desconecta), pero a menudo es necesario identificar a los pares mediante un valor numérico.

Esto es exactamente para lo que se utilizan las funciones. resolve и reverseResolve: La aplicación de alguna manera recibe el valor de cadena de la clave (a través de la entrada del usuario o mediante el servidor maestro) y puede convertirlo en una dirección IP para uso interno. El resto de la API también recibe este valor en lugar de una cadena para simplificar.

Esto es similar a la búsqueda de DNS, pero se realiza localmente en el cliente.

Es decir, las direcciones IP no se pueden compartir entre diferentes clientes, y si se necesita algún tipo de identificador global, habrá que generarlo de otra forma.

Conexión perezosa

UDP no necesita una conexión, pero como hemos visto, WebRTC requiere un largo proceso de conexión antes de poder comenzar a transferir datos entre dos pares.

Si queremos proporcionar el mismo nivel de abstracción, (sendto/recvfrom con pares arbitrarios sin conexión previa), entonces deben realizar una conexión “lazy” (retrasada) dentro de la API.

Esto es lo que sucede durante la comunicación normal entre el “servidor” y el “cliente” cuando se usa UDP, y lo que debería hacer nuestra biblioteca:

  • llamadas al servidor bind()para indicarle al sistema operativo que desea recibir paquetes en el puerto especificado.

En su lugar, publicaremos un puerto abierto en Firebase bajo la clave del servidor y escucharemos eventos en su subárbol.

  • llamadas al servidor recvfrom(), aceptando paquetes provenientes de cualquier host en este puerto.

En nuestro caso, necesitamos verificar la cola entrante de paquetes enviados a este puerto.

Cada puerto tiene su propia cola, y agregamos los puertos de origen y destino al comienzo de los datagramas WebRTC para saber a qué cola reenviar cuando llegue un nuevo paquete.

La llamada no es de bloqueo, por lo que si no hay paquetes, simplemente devolvemos -1 y configuramos errno=EWOULDBLOCK.

  • El cliente recibe la IP y el puerto del servidor por algún medio externo y llama sendto(). Esto también hace una llamada interna. bind(), por lo tanto posterior recvfrom() recibirá la respuesta sin ejecutar explícitamente bind.

En nuestro caso, el cliente recibe externamente la clave de cadena y usa la función resolve() para obtener una dirección IP.

En este punto, iniciamos un protocolo de enlace WebRTC si los dos pares aún no están conectados entre sí. Las conexiones a diferentes puertos del mismo par utilizan el mismo WebRTC DataChannel.

También realizamos indirectos bind()para que el servidor pueda volver a conectarse en la próxima sendto() en caso de que cerrara por algún motivo.

Se notifica al servidor sobre la conexión del cliente cuando el cliente escribe su oferta SDP en la información del puerto del servidor en Firebase, y el servidor responde con su respuesta allí.

El siguiente diagrama muestra un ejemplo de flujo de mensajes para un esquema de socket y la transmisión del primer mensaje del cliente al servidor:

Portar un juego multijugador de C++ a la web con Cheerp, WebRTC y Firebase
Esquema completo de la fase de conexión entre cliente y servidor.

Conclusión

Si ha leído hasta aquí, probablemente le interese ver la teoría en acción. El juego se puede jugar en teeworlds.leaningtech.com, ¡intentalo!


Partido amistoso entre compañeros

El código de la biblioteca de la red está disponible gratuitamente en Github. Únase a la conversación en nuestro canal en cuadrícula!

Fuente: habr.com

Añadir un comentario