Portando um jogo multijogador de C++ para a web com Cheerp, WebRTC e Firebase

Introdução

nossa empresa Tecnologias enxutas fornece soluções para portar aplicativos de desktop tradicionais para a web. Nosso compilador C++ animar gera uma combinação de WebAssembly e JavaScript, que fornece ambos interação simples do navegadore alto desempenho.

Como exemplo de aplicação, decidimos portar um jogo multiplayer para a web e escolhemos Teeworlds. Teeworlds é um jogo retrô XNUMXD multijogador com uma comunidade pequena, mas ativa de jogadores (inclusive eu!). É pequeno tanto em termos de recursos baixados quanto de requisitos de CPU e GPU – um candidato ideal.

Portando um jogo multijogador de C++ para a web com Cheerp, WebRTC e Firebase
Executando no navegador Teeworlds

Decidimos usar este projeto para experimentar soluções gerais para portar código de rede para a web. Isso geralmente é feito das seguintes maneiras:

  • XMLHttpRequest/busca, se a parte da rede consistir apenas em solicitações HTTP, ou
  • WebSockets.

Ambas as soluções requerem hospedagem de um componente de servidor no lado do servidor e nenhuma delas permite o uso como protocolo de transporte UDP. Isto é importante para aplicações em tempo real, como software de videoconferência e jogos, porque garante a entrega e a ordem dos pacotes de protocolo. TCP pode se tornar um obstáculo à baixa latência.

Existe uma terceira maneira - use a rede do navegador: WebRTC.

RTCDataChannel Ele suporta transmissão confiável e não confiável (neste último caso tenta usar UDP como protocolo de transporte sempre que possível) e pode ser usado tanto com um servidor remoto quanto entre navegadores. Isso significa que podemos portar toda a aplicação para o navegador, incluindo o componente do servidor!

No entanto, isso traz uma dificuldade adicional: antes que dois peers WebRTC possam se comunicar, eles precisam realizar um handshake relativamente complexo para se conectar, o que requer várias entidades terceirizadas (um servidor de sinalização e um ou mais servidores ATORDOAR/VIRAR).

Idealmente, gostaríamos de criar uma API de rede que use WebRTC internamente, mas que seja o mais próximo possível de uma interface UDP Sockets que não precise estabelecer uma conexão.

Isso nos permitirá aproveitar as vantagens do WebRTC sem ter que expor detalhes complexos ao código do aplicativo (que queríamos alterar o mínimo possível em nosso projeto).

WebRTC mínimo

WebRTC é um conjunto de APIs disponíveis em navegadores que fornece transmissão ponto a ponto de áudio, vídeo e dados arbitrários.

A conexão entre peers é estabelecida (mesmo que haja NAT em um ou ambos os lados) utilizando servidores STUN e/ou TURN através de um mecanismo chamado ICE. Os pares trocam informações ICE e parâmetros de canal por meio de oferta e resposta do protocolo SDP.

Uau! Quantas abreviaturas ao mesmo tempo? Vamos explicar brevemente o que esses termos significam:

  • Utilitários de travessia de sessão para NAT (ATORDOAR) — um protocolo para contornar o NAT e obter um par (IP, porta) para troca de dados diretamente com o host. Se ele conseguir completar sua tarefa, os pares poderão trocar dados de forma independente entre si.
  • Traversal usando relés em torno do NAT (VIRAR) também é usado para travessia NAT, mas implementa isso encaminhando dados por meio de um proxy que é visível para ambos os pares. Acrescenta latência e é mais caro de implementar que o STUN (porque é aplicado durante toda a sessão de comunicação), mas às vezes é a única opção.
  • Estabelecimento de Conectividade Interativa (ICE) usado para selecionar o melhor método possível de conexão de dois pares com base nas informações obtidas diretamente da conexão dos pares, bem como nas informações recebidas por qualquer número de servidores STUN e TURN.
  • Protocolo de Descrição da Sessão (SDP) é um formato para descrever parâmetros de canais de conexão, por exemplo, candidatos ICE, codecs multimídia (no caso de um canal de áudio/vídeo), etc... Um dos pares envia uma Oferta SDP, e o segundo responde com uma Resposta SDP . Depois disso, um canal é criado.

Para criar tal conexão, os pares precisam coletar as informações que recebem dos servidores STUN e TURN e trocá-las entre si.

O problema é que eles ainda não têm a capacidade de se comunicar diretamente, então deve existir um mecanismo fora de banda para trocar esses dados: um servidor de sinalização.

Um servidor de sinalização pode ser muito simples porque sua única função é encaminhar dados entre pares na fase de handshake (conforme mostrado no diagrama abaixo).

Portando um jogo multijogador de C++ para a web com Cheerp, WebRTC e Firebase
Diagrama de sequência de handshake WebRTC simplificado

Visão geral do modelo de rede Teeworlds

A arquitetura de rede da Teeworlds é muito simples:

  • Os componentes cliente e servidor são dois programas diferentes.
  • Os clientes entram no jogo conectando-se a um dos vários servidores, cada um hospedando apenas um jogo por vez.
  • Toda a transferência de dados no jogo é realizada através do servidor.
  • Um servidor mestre especial é usado para coletar uma lista de todos os servidores públicos exibidos no cliente do jogo.

Graças ao uso do WebRTC para troca de dados, podemos transferir o componente servidor do jogo para o navegador onde o cliente está localizado. Isso nos dá uma grande oportunidade...

Livre-se dos servidores

A falta de lógica de servidor tem uma boa vantagem: podemos implantar todo o aplicativo como conteúdo estático nas páginas do Github ou em nosso próprio hardware por trás do Cloudflare, garantindo assim downloads rápidos e alto tempo de atividade gratuitamente. Na verdade, podemos esquecê-los e, se tivermos sorte e o jogo se tornar popular, a infraestrutura não precisará ser modernizada.

Porém, para que o sistema funcione, ainda temos que utilizar uma arquitetura externa:

  • Um ou mais servidores STUN: Temos diversas opções gratuitas para você escolher.
  • Pelo menos um servidor TURN: não há opções gratuitas aqui, então podemos configurar o nosso próprio ou pagar pelo serviço. Felizmente, na maioria das vezes a conexão pode ser estabelecida através de servidores STUN (e fornecer verdadeiro p2p), mas o TURN é necessário como uma opção alternativa.
  • Servidor de Sinalização: Ao contrário dos outros dois aspectos, a sinalização não é padronizada. O que o servidor de sinalização será realmente responsável depende um pouco da aplicação. No nosso caso, antes de estabelecer uma conexão, é necessário trocar uma pequena quantidade de dados.
  • Teeworlds Master Server: É usado por outros servidores para anunciar sua existência e por clientes para encontrar servidores públicos. Embora não seja obrigatório (os clientes sempre podem se conectar a um servidor que conhecem manualmente), seria bom que os jogadores pudessem participar de jogos com pessoas aleatórias.

Decidimos usar os servidores STUN gratuitos do Google e implantamos nós mesmos um servidor TURN.

Para os dois últimos pontos usamos Firebase:

  • O servidor mestre Teeworlds é implementado de forma muito simples: como uma lista de objetos contendo informações (nome, IP, mapa, modo, ...) de cada servidor ativo. Os servidores publicam e atualizam seu próprio objeto, e os clientes pegam a lista inteira e a exibem ao player. Também exibimos a lista na página inicial como HTML para que os jogadores possam simplesmente clicar no servidor e serem levados diretamente para o jogo.
  • A sinalização está intimamente relacionada à implementação de nossos soquetes, descrita na próxima seção.

Portando um jogo multijogador de C++ para a web com Cheerp, WebRTC e Firebase
Lista de servidores dentro do jogo e na página inicial

Implementação de soquetes

Queremos criar uma API que seja o mais próximo possível dos soquetes Posix UDP para minimizar o número de alterações necessárias.

Também queremos implementar o mínimo necessário para a troca de dados mais simples na rede.

Por exemplo, não precisamos de roteamento real: todos os pares estão na mesma “LAN virtual” associada a uma instância específica do banco de dados Firebase.

Portanto, não precisamos de endereços IP exclusivos: valores de chave exclusivos do Firebase (semelhantes aos nomes de domínio) são suficientes para identificar pares de maneira exclusiva, e cada par atribui localmente endereços IP "falsos" a cada chave que precisa ser traduzida. Isto elimina completamente a necessidade de atribuição global de endereços IP, que não é uma tarefa trivial.

Aqui está a API mínima que precisamos 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);

A API é simples e semelhante à API Posix Sockets, mas possui algumas diferenças importantes: registrar retornos de chamada, atribuir IPs locais e conexões lentas.

Registrando retornos de chamada

Mesmo que o programa original use E/S sem bloqueio, o código deve ser refatorado para ser executado em um navegador da web.

A razão para isso é que o loop de eventos no navegador está oculto do programa (seja JavaScript ou WebAssembly).

No ambiente nativo podemos escrever 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;
    ...
  }
  ...
}

Se o loop de eventos estiver oculto para nós, precisamos transformá-lo em algo assim:

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

Atribuição de IP local

Os IDs dos nós em nossa "rede" não são endereços IP, mas chaves do Firebase (são strings parecidas com esta: -LmEC50PYZLCiCP-vqde ).

Isso é conveniente porque não precisamos de um mecanismo para atribuir IPs e verificar sua exclusividade (bem como descartá-los após a desconexão do cliente), mas muitas vezes é necessário identificar os pares por um valor numérico.

É exatamente para isso que as funções são usadas. resolve и reverseResolve: O aplicativo de alguma forma recebe o valor da string da chave (por meio da entrada do usuário ou do servidor mestre) e pode convertê-lo em um endereço IP para uso interno. O restante da API também recebe esse valor em vez de uma string para simplificar.

Isto é semelhante à pesquisa de DNS, mas realizada localmente no cliente.

Ou seja, endereços IP não podem ser compartilhados entre clientes diferentes e, caso seja necessário algum tipo de identificador global, ele deverá ser gerado de forma diferente.

Conexão preguiçosa

O UDP não precisa de conexão, mas como vimos, o WebRTC requer um longo processo de conexão antes de poder começar a transferir dados entre dois pares.

Se quisermos fornecer o mesmo nível de abstração, (sendto/recvfrom com pares arbitrários sem conexão prévia), então eles devem realizar uma conexão “preguiçosa” (atrasada) dentro da API.

Isto é o que acontece durante a comunicação normal entre o “servidor” e o “cliente” ao usar UDP, e o que nossa biblioteca deve fazer:

  • Chamadas de servidor bind()para informar ao sistema operacional que deseja receber pacotes na porta especificada.

Em vez disso, publicaremos uma porta aberta para o Firebase na chave do servidor e escutaremos eventos em sua subárvore.

  • Chamadas de servidor recvfrom(), aceitando pacotes vindos de qualquer host nesta porta.

No nosso caso, precisamos verificar a fila de entrada de pacotes enviados para esta porta.

Cada porta tem sua própria fila e adicionamos as portas de origem e destino ao início dos datagramas WebRTC para sabermos para qual fila encaminhar quando um novo pacote chegar.

A chamada não é bloqueante, portanto, se não houver pacotes, simplesmente retornamos -1 e definimos errno=EWOULDBLOCK.

  • O cliente recebe o IP e a porta do servidor por algum meio externo e chama sendto(). Isso também faz uma chamada interna. bind(), portanto subsequente recvfrom() receberá a resposta sem executar explicitamente o bind.

No nosso caso, o cliente recebe externamente a chave da string e utiliza a função resolve() para obter um endereço IP.

Neste ponto, iniciamos um handshake WebRTC se os dois pares ainda não estiverem conectados um ao outro. Conexões com portas diferentes do mesmo peer usam o mesmo DataChannel WebRTC.

Também realizamos indiretas bind()para que o servidor possa se reconectar na próxima sendto() caso tenha fechado por algum motivo.

O servidor é notificado sobre a conexão do cliente quando o cliente escreve sua oferta SDP nas informações da porta do servidor no Firebase, e o servidor responde com sua resposta lá.

O diagrama abaixo mostra um exemplo de fluxo de mensagens para um esquema de soquete e a transmissão da primeira mensagem do cliente para o servidor:

Portando um jogo multijogador de C++ para a web com Cheerp, WebRTC e Firebase
Diagrama completo da fase de conexão entre cliente e servidor

Conclusão

Se você leu até aqui, provavelmente está interessado em ver a teoria em ação. O jogo pode ser jogado em teeworlds.leaningtech.com, tente!


Amistoso entre colegas

O código da biblioteca de rede está disponível gratuitamente em Github. Participe da conversa em nosso canal em Grade!

Fonte: habr.com

Adicionar um comentário