Portar un joc multijugador de C++ al web amb Cheerp, WebRTC i Firebase

Introducció

la nostra companyia Tecnologies inclinades ofereix solucions per portar aplicacions d'escriptori tradicionals al web. El nostre compilador C++ Cheerp genera una combinació de WebAssembly i JavaScript, que proporciona i fàcil interacció amb el navegador, i alt rendiment.

Com a exemple del seu ús, vam decidir portar un joc multijugador per a la web i vam triar Teeworlds. Teeworlds és un joc multijugador retro en XNUMXD amb una petita però activa comunitat de jugadors (incloent-me jo!). És petit tant pel que fa als recursos descarregables com als requisits de CPU i GPU, un candidat ideal.

Portar un joc multijugador de C++ al web amb Cheerp, WebRTC i Firebase
S'està executant al navegador Teeworlds

Vam decidir utilitzar aquest projecte per experimentar solucions generals per portar codi de xarxa al web. Això es fa normalment de les maneres següents:

  • XMLHttpRequest/fetch, si la part de xarxa només consta de sol·licituds HTTP, o bé
  • endolls web.

Ambdues solucions requereixen allotjar un component del servidor al costat del servidor i cap de les dues permet que s'utilitzi com a protocol de transport. UDP. Això és important per a aplicacions en temps real com ara programari de videoconferència i jocs perquè el lliurament de paquets i la comanda del protocol garanteixen TCP pot interferir amb una baixa latència.

Hi ha una tercera manera: utilitzar la xarxa des del navegador: WebRTC.

RTCDataChannel suporta la transmissió tant fiable com no fiable (en aquest últim cas, intenta utilitzar UDP com a protocol de transport sempre que sigui possible) i es pot utilitzar tant amb un servidor remot com entre navegadors. Això vol dir que podem portar tota l'aplicació al navegador, inclòs el component del servidor!

Tanmateix, això comporta una dificultat addicional: abans que dos iguals de WebRTC es puguin comunicar, han de realitzar una encaixada de mans relativament complexa per connectar-se, que requereix múltiples entitats de tercers (un servidor de senyalització i un o més). ESTUFA/TORNAR).

Idealment, ens agradaria crear una API de xarxa que utilitzi WebRTC internament, però que sigui el més propera possible a la interfície UDP Sockets, que no necessita establir una connexió.

Això ens permetrà aprofitar WebRTC sense haver d'exposar detalls complexos al codi de l'aplicació (que volíem canviar el menys possible en el nostre projecte).

WebRTC mínim

WebRTC és un conjunt d'API disponibles als navegadors per a la transmissió peer-to-peer d'àudio, vídeo i dades arbitràries.

La connexió entre iguals s'estableix (encara que hi hagi NAT en un o ambdós costats) mitjançant servidors STUN i/o TURN mitjançant un mecanisme anomenat ICE. Els companys intercanvien informació de l'ICE i paràmetres de canal mitjançant l'oferta i resposta del protocol SDP.

Vaja! Quantes abreviatures alhora. Expliquem breument què volen dir aquests termes:

  • Utilitats de recorregut de sessió per a NAT (ESTUFA) - un protocol per evitar el NAT i obtenir un parell (IP, port) per comunicar-se directament amb l'amfitrió. Si aconsegueix completar la seva tasca, els companys poden intercanviar dades de manera independent entre ells.
  • Travessia mitjançant relés al voltant de NAT (TORNAR) també s'utilitza per evitar NAT, però ho fa enviant dades a través d'un servidor intermediari visible per als dos companys. Afegeix latència i és més car d'executar que STUN (perquè s'aplica durant tota la sessió), però de vegades és l'única opció.
  • Establiment de connectivitat interactiva (ICE) s'utilitza per seleccionar la millor manera possible de connectar dos iguals en funció de la informació obtinguda mitjançant la connexió directa dels iguals, així com la informació rebuda per qualsevol nombre de servidors STUN i TURN.
  • Protocol de descripció de la sessió (SDP) - aquest és un format per descriure els paràmetres del canal de connexió, per exemple, candidats ICE, còdecs multimèdia (en el cas d'un canal d'àudio / vídeo), etc. Un dels companys envia una oferta SDP ("oferta") i el segon respon amb una resposta SDP ("resposta"). Després d'això, es crea un canal.

Per crear aquesta connexió, els companys han de recopilar la informació que reben dels servidors STUN i TURN i intercanviar-la entre ells.

El problema és que encara no tenen la capacitat d'intercanviar dades directament, per la qual cosa ha d'existir un mecanisme fora de banda per intercanviar aquestes dades: un servidor de senyalització.

El servidor de senyalització pot ser molt senzill, perquè la seva única tasca és reenviar dades entre iguals durant l'etapa de "encaixada de mans" (com es mostra al diagrama següent).

Portar un joc multijugador de C++ al web amb Cheerp, WebRTC i Firebase
Seqüència d'encaix de mans WebRTC simplificada

Visió general del model de xarxa de Teeworlds

L'arquitectura de xarxa de Teeworlds és molt senzilla:

  • Els components client i servidor són dos programes diferents.
  • Els clients entren al joc connectant-se a un dels diversos servidors, cadascun allotjant només un joc alhora.
  • Tota la transferència de dades del joc es realitza a través del servidor.
  • S'utilitza un servidor mestre especial per recopilar una llista de tots els servidors públics que es mostren al client del joc.

Gràcies a l'ús de WebRTC per a l'intercanvi de dades, podem transferir el component del servidor del joc al navegador on es troba el client. Això ens dóna una gran oportunitat...

Desfer-se dels servidors

L'absència de lògica del costat del servidor té un gran avantatge: podem desplegar tota l'aplicació com a contingut estàtic a Github Pages o al nostre propi maquinari darrere de Cloudflare, garantint així descàrregues ràpides i un alt temps de funcionament de forma gratuïta. De fet, es podrà oblidar d'ells, i si tenim sort i el joc es fa popular, no caldrà actualitzar la infraestructura.

Tanmateix, perquè el sistema funcioni, encara hem d'utilitzar una arquitectura externa:

  • Un o més servidors STUN: tenim diverses opcions gratuïtes per triar.
  • Almenys un servidor TURN: no hi ha opcions gratuïtes aquí, de manera que podem configurar el nostre o pagar el servei. Afortunadament, la majoria de les vegades serà possible connectar-se mitjançant servidors STUN (i proporcionar p2p veritable), però TURN és necessari com a alternativa.
  • Servidor de senyalització: a diferència dels altres dos aspectes, la senyalització no està estandarditzada. De què serà realment responsable el servidor de senyalització depèn una mica de l'aplicació. En el nostre cas, abans d'establir una connexió, cal intercanviar una petita quantitat de dades.
  • Servidor mestre Teeworlds: és utilitzat per altres servidors per anunciar la seva existència i pels clients per trobar servidors públics. Tot i que no és necessari (els clients sempre es poden connectar manualment a un servidor que coneixen), seria bo tenir-ne un perquè els jugadors puguin jugar amb persones aleatòries.

Vam decidir utilitzar els servidors STUN gratuïts de Google i vam implementar un servidor TURN nosaltres mateixos.

Per als dos últims elements, hem utilitzat Base de dades:

  • El servidor mestre de Teeworlds s'implementa de manera molt senzilla: com una llista d'objectes que contenen informació (nom, IP, mapa, mode, ...) de cada servidor actiu. Els servidors publiquen i actualitzen el seu propi objecte, i els clients prenen la llista sencera i la mostren al jugador. També mostrem la llista com a HTML a la pàgina d'inici perquè els jugadors simplement puguin fer clic al servidor i saltar directament al joc.
  • La senyalització està estretament relacionada amb la nostra implementació de sòcols, que es descriu a la secció següent.

Portar un joc multijugador de C++ al web amb Cheerp, WebRTC i Firebase
Llista de servidors dins del joc i a la pàgina d'inici

Implementació de socket

Volem crear una API que sigui el més propera possible als sockets UDP de Posix per tal de minimitzar el nombre de canvis necessaris.

També volem implementar el mínim necessari per a l'intercanvi de dades més senzill a través de la xarxa.

Per exemple, no necessitem un encaminament real: tots els iguals estan a la mateixa "LAN virtual" associada a una instància de base de dades de Firebase concreta.

Per tant, no necessitem adreces IP úniques: per identificar de manera única els iguals, n'hi ha prou amb utilitzar valors de clau de Firebase únics (similars als noms de domini) i cada igual assigna localment adreces IP "falses" a cada clau que cal ser. traduït. Això ens estalvia completament d'haver d'assignar adreces IP globalment, cosa que no és una tasca trivial.

Aquí teniu l'API mínima que hem d'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);

L'API és senzilla i semblant a l'API Posix Sockets, però amb algunes diferències importants: registre de devolució de trucada, assignació d'IP local i connexió mandrosa.

Registre de devolució de trucades

Fins i tot si el programa original utilitza E/S sense bloqueig, el codi s'ha de refactoritzar per executar-lo en un navegador web.

La raó d'això és que el bucle d'esdeveniments al navegador està ocult al programa (ja sigui JavaScript o WebAssembly).

En un entorn natiu, podem escriure codi com aquest

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 d'esdeveniments està amagat per a nosaltres, hem de convertir-lo en alguna cosa com això:

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

Assignació d'IP local

Els ID de nodes de la nostra "xarxa" no són adreces IP, sinó claus de Firebase (són cadenes que semblen així: -LmEC50PYZLCiCP-vqde ).

Això és convenient perquè no necessitem un mecanisme per assignar IP i comprovar si són úniques (i eliminar-les després que un client es desconnecti), però sovint és necessari identificar els iguals per valor numèric.

Per a això serveixen les funcions. resolve и reverseResolve: L'aplicació rep d'alguna manera el valor de cadena de la clau (mitjançant l'entrada de l'usuari o mitjançant el servidor mestre) i pot resoldre'l a una adreça IP per a ús intern. La resta de l'API també rep aquest valor en lloc d'una cadena per simplificar.

Això és similar a una cerca de DNS, només es fa localment al client.

És a dir, les adreces IP no es poden compartir entre diferents clients, i si es necessita algun tipus d'identificador global, s'haurà de generar d'una altra manera.

Uneix-te mandrós

UDP no necessita connexió, però com hem vist, WebRTC requereix un procés de connexió llarg abans de poder començar a transferir dades entre dos iguals.

Si volem proporcionar el mateix nivell d'abstracció, (sendto/recvfrom amb iguals arbitraris sense una connexió prèvia), llavors han de realitzar una connexió "mandra" (retardada) dins de l'API.

Aquí teniu el que passa en la comunicació normal entre el "servidor" i el "client" en el cas d'utilitzar UDP, i què hauria de fer la nostra biblioteca:

  • Trucades del servidor bind()per indicar al sistema operatiu que vol rebre paquets al port especificat.

En canvi, publicarem un port obert a Firebase sota la clau del servidor i escoltarem els esdeveniments al seu subarbre.

  • Trucades del servidor recvfrom(), acceptant paquets de qualsevol host d'aquest port.

En el nostre cas, hem de comprovar la cua entrant de paquets enviats a aquest port.

Cada port té la seva pròpia cua i afegim ports d'origen i de destinació a l'inici dels datagrames WebRTC perquè sabem a quina cua redirigir quan arribi un nou paquet.

La trucada no es bloqueja, de manera que si no hi ha paquets, simplement retornem -1 i posem errno=EWOULDBLOCK.

  • El client rep per algun mitjà extern la IP i el port del servidor, i truca sendto(). També fa una trucada interna bind(), doncs el següent recvfrom() rebrà la resposta sense executar explícitament l'enllaç.

En el nostre cas, el client rep externament una clau de cadena i utilitza la funció resolve() per obtenir una adreça IP.

En aquest punt, comencem la connexió de mans WebRTC si els dos iguals encara no estan connectats entre si. Les connexions a diferents ports del mateix igual utilitzen el mateix canal de dades WebRTC.

També fem indirectes bind()perquè el servidor es pugui tornar a connectar a continuació sendto() en cas que tanqués per algun motiu.

El servidor es notifica de la connexió del client quan el client escriu la seva oferta SDP sota la informació del port del servidor a Firebase, i el servidor també respon amb la seva resposta allà.

El diagrama següent mostra un exemple de flux de missatges per a l'esquema de sòcols i el primer missatge del client al servidor:

Portar un joc multijugador de C++ al web amb Cheerp, WebRTC i Firebase
Esquema complet de la fase de connexió entre client i servidor

Conclusió

Si heu llegit fins aquí, potser us interessa veure la teoria en acció. El joc es pot jugar teeworlds.leaningtech.com, intenta-ho!


Partit amistós entre companys

El codi de la biblioteca de la xarxa està disponible gratuïtament a Github. Uneix-te a la conversa al nostre canal Gitter!

Font: www.habr.com

Afegeix comentari