Portarea unui joc multiplayer din C++ pe web cu Cheerp, WebRTC și Firebase

Introducere

compania noastră Tehnologii înclinate oferă soluții pentru portarea aplicațiilor desktop tradiționale pe web. Compilatorul nostru C++ înveseli generează o combinație de WebAssembly și JavaScript, care furnizează și interacțiune simplă cu browserul, și de înaltă performanță.

Ca exemplu de aplicare, am decis să port un joc multiplayer pe web și am ales Teeworlds. Teeworlds este un joc retro XNUMXD multiplayer cu o comunitate mică, dar activă de jucători (inclusiv eu!). Este mic atât în ​​ceea ce privește resursele descărcate, cât și cerințele CPU și GPU - un candidat ideal.

Portarea unui joc multiplayer din C++ pe web cu Cheerp, WebRTC și Firebase
Rulează în browserul Teeworlds

Am decis să folosim acest proiect pentru a experimenta soluții generale pentru portarea codului de rețea pe web. Acest lucru se face de obicei în următoarele moduri:

  • XMLHttpRequest/fetch, dacă partea de rețea constă numai din solicitări HTTP sau
  • prize web.

Ambele soluții necesită găzduirea unei componente de server pe partea de server și nici una nu permite utilizarea ca protocol de transport UDP. Acest lucru este important pentru aplicațiile în timp real, cum ar fi software-ul de videoconferință și jocuri, deoarece garantează livrarea și comanda pachetelor de protocol. TCP poate deveni un obstacol în calea latenței scăzute.

Există o a treia modalitate - utilizați rețeaua din browser: WebRTC.

RTCDataChannel Suportă atât transmisie fiabilă, cât și nesigură (în acest din urmă caz ​​încearcă să folosească UDP ca protocol de transport ori de câte ori este posibil) și poate fi folosit atât cu un server la distanță, cât și între browsere. Aceasta înseamnă că putem porta întreaga aplicație în browser, inclusiv componenta server!

Totuși, acest lucru vine cu o dificultate suplimentară: înainte ca doi colegi WebRTC să poată comunica, aceștia trebuie să efectueze o strângere de mână relativ complexă pentru a se conecta, care necesită mai multe entități terțe (un server de semnalizare și unul sau mai multe servere). STUN/ÎNTORCĂ).

În mod ideal, am dori să creăm un API de rețea care să folosească WebRTC intern, dar să fie cât mai aproape posibil de o interfață UDP Sockets care nu trebuie să stabilească o conexiune.

Acest lucru ne va permite să profităm de WebRTC fără a fi nevoie să expunem detalii complexe codului aplicației (pe care am vrut să-l schimbăm cât mai puțin posibil în proiectul nostru).

WebRTC minim

WebRTC este un set de API-uri disponibile în browsere care oferă transmisie peer-to-peer a datelor audio, video și arbitrare.

Conexiunea dintre egali este stabilită (chiar dacă există NAT pe una sau ambele părți) folosind servere STUN și/sau TURN printr-un mecanism numit ICE. Peers schimbă informații ICE și parametrii canalului prin oferta și răspunsul protocolului SDP.

Wow! Câte abrevieri deodată? Să explicăm pe scurt ce înseamnă acești termeni:

  • Utilitare de traversare a sesiunii pentru NAT (STUN) — un protocol pentru ocolirea NAT și obținerea unei perechi (IP, port) pentru schimbul de date direct cu gazda. Dacă reușește să-și îndeplinească sarcina, atunci colegii pot face schimb de date în mod independent între ei.
  • Traversare folosind relee în jurul NAT (ÎNTORCĂ) este folosit și pentru traversarea NAT, dar implementează acest lucru prin transmiterea datelor printr-un proxy care este vizibil pentru ambii colegi. Adaugă latență și este mai costisitor de implementat decât STUN (pentru că este aplicat pe toată durata sesiunii de comunicare), dar uneori este singura opțiune.
  • Stabilirea de conectivitate interactivă (ICE) folosit pentru a selecta cea mai bună metodă posibilă de conectare a doi colegi pe baza informațiilor obținute din conectarea directă a colegilor, precum și a informațiilor primite de orice număr de servere STUN și TURN.
  • Protocolul de descriere a sesiunii (PSD) este un format pentru descrierea parametrilor canalului de conexiune, de exemplu, candidați ICE, codecuri multimedia (în cazul unui canal audio/video), etc... Unul dintre colegi trimite o Oferta SDP, iar al doilea răspunde cu un Răspuns SDP . . După aceasta, este creat un canal.

Pentru a crea o astfel de conexiune, colegii trebuie să colecteze informațiile pe care le primesc de la serverele STUN și TURN și să le schimbe între ei.

Problema este că nu au încă capacitatea de a comunica direct, așa că trebuie să existe un mecanism out-of-band pentru a schimba aceste date: un server de semnalizare.

Un server de semnalizare poate fi foarte simplu, deoarece singura lui sarcină este de a transmite date între egali în faza de strângere de mână (așa cum se arată în diagrama de mai jos).

Portarea unui joc multiplayer din C++ pe web cu Cheerp, WebRTC și Firebase
Diagrama simplificată a secvenței de strângere de mână WebRTC

Prezentare generală a modelului de rețea Teeworlds

Arhitectura rețelei Teeworlds este foarte simplă:

  • Componentele client și server sunt două programe diferite.
  • Clienții intră în joc conectându-se la unul dintre mai multe servere, fiecare dintre ele găzduind doar un joc la un moment dat.
  • Toate transferurile de date din joc sunt efectuate prin server.
  • Un server master special este folosit pentru a colecta o listă a tuturor serverelor publice care sunt afișate în clientul jocului.

Datorită utilizării WebRTC pentru schimbul de date, putem transfera componenta server a jocului în browserul în care se află clientul. Acest lucru ne oferă o mare oportunitate...

Scapa de servere

Lipsa logicii serverului are un avantaj frumos: putem implementa întreaga aplicație ca conținut static pe Github Pages sau pe propriul nostru hardware din spatele Cloudflare, asigurând astfel descărcări rapide și timp de funcționare ridicat gratuit. De fapt, putem uita de ele, iar dacă avem noroc și jocul devine popular, atunci infrastructura nu va trebui modernizată.

Cu toate acestea, pentru ca sistemul să funcționeze, mai trebuie să folosim o arhitectură externă:

  • Unul sau mai multe servere STUN: Avem mai multe opțiuni gratuite din care să alegeți.
  • Cel puțin un server TURN: nu există opțiuni gratuite aici, așa că putem fie să ne instalăm propriul server, fie să plătim pentru serviciu. Din fericire, de cele mai multe ori conexiunea poate fi stabilită prin servere STUN (și oferă p2p adevărat), dar TURN este necesar ca opțiune de rezervă.
  • Server de semnalizare: Spre deosebire de celelalte două aspecte, semnalizarea nu este standardizată. De ce va fi de fapt responsabil serverul de semnalizare depinde oarecum de aplicație. În cazul nostru, înainte de a stabili o conexiune, este necesar să facem schimb de o cantitate mică de date.
  • Teeworlds Master Server: Este folosit de alte servere pentru a-și face publicitate existenței și de clienți pentru a găsi servere publice. Deși nu este obligatoriu (clienții se pot conecta oricând la un server despre care știu manual), ar fi bine să existe, astfel încât jucătorii să poată participa la jocuri cu persoane aleatorii.

Am decis să folosim serverele STUN gratuite de la Google și am implementat singuri un server TURN.

Pentru ultimele două puncte am folosit Firebase:

  • Serverul principal Teeworlds este implementat foarte simplu: ca o listă de obiecte care conțin informații (nume, IP, hartă, mod, ...) ale fiecărui server activ. Serverele își publică și își actualizează propriul obiect, iar clienții preiau întreaga listă și o afișează jucătorului. De asemenea, afișăm lista pe pagina de pornire ca HTML, astfel încât jucătorii să poată pur și simplu să facă clic pe server și să fie duși direct la joc.
  • Semnalizarea este strâns legată de implementarea socket-urilor noastre, descrisă în secțiunea următoare.

Portarea unui joc multiplayer din C++ pe web cu Cheerp, WebRTC și Firebase
Lista serverelor din interiorul jocului și de pe pagina de pornire

Implementarea prize

Dorim să creăm un API cât mai aproape de Posix UDP Sockets pentru a minimiza numărul de modificări necesare.

De asemenea, dorim să implementăm minimul necesar pentru cel mai simplu schimb de date prin rețea.

De exemplu, nu avem nevoie de rutare reală: toți colegii sunt pe aceeași „LAN virtuală” asociată cu o anumită instanță a bazei de date Firebase.

Prin urmare, nu avem nevoie de adrese IP unice: valorile unice ale cheilor Firebase (asemănătoare cu numele de domenii) sunt suficiente pentru a identifica în mod unic colegii, iar fiecare egal atribuie local adrese IP „false” fiecărei chei care trebuie tradusă. Acest lucru elimină complet nevoia de atribuire globală a adresei IP, care este o sarcină netrivială.

Iată API-ul minim pe care trebuie să îl implementăm:

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

API-ul este simplu și similar cu API-ul Posix Sockets, dar are câteva diferențe importante: înregistrarea apelurilor inverse, atribuirea de IP-uri locale și conexiuni leneșe.

Înregistrarea apelurilor inverse

Chiar dacă programul original folosește I/O non-blocante, codul trebuie refactorizat pentru a rula într-un browser web.

Motivul pentru aceasta este că bucla de evenimente din browser este ascunsă de program (fie el JavaScript sau WebAssembly).

În mediul nativ putem scrie cod astfel

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

Dacă bucla de evenimente ne este ascunsă, atunci trebuie să o transformăm în ceva de genul acesta:

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

Alocarea IP locală

ID-urile nodurilor din „rețea” noastră nu sunt adrese IP, ci chei Firebase (sunt șiruri care arată astfel: -LmEC50PYZLCiCP-vqde ).

Acest lucru este convenabil deoarece nu avem nevoie de un mecanism pentru atribuirea IP-urilor și verificarea unicității acestora (precum și eliminarea lor după deconectarea clientului), dar este adesea necesar să identificăm colegii printr-o valoare numerică.

Exact pentru asta sunt folosite funcțiile. resolve и reverseResolve: Aplicația primește cumva valoarea șirului cheii (prin introducerea utilizatorului sau prin serverul principal) și o poate converti într-o adresă IP pentru uz intern. Restul API-ului primește și această valoare în loc de un șir pentru simplitate.

Aceasta este similară cu căutarea DNS, dar este efectuată local pe client.

Adică, adresele IP nu pot fi partajate între diferiți clienți și, dacă este nevoie de un anumit identificator global, acesta va trebui generat într-un mod diferit.

Conexiune leneșă

UDP nu are nevoie de o conexiune, dar, după cum am văzut, WebRTC necesită un proces de conexiune lung înainte de a putea începe transferul de date între doi colegi.

Dacă vrem să oferim același nivel de abstractizare, (sendto/recvfrom cu colegii arbitrari fără conexiune prealabilă), atunci trebuie să efectueze o conexiune „lenenă” (întârziată) în interiorul API.

Iată ce se întâmplă în timpul comunicării normale între „server” și „client” atunci când utilizați UDP și ce ar trebui să facă biblioteca noastră:

  • Apeluri pe server bind()pentru a spune sistemului de operare că dorește să primească pachete pe portul specificat.

În schimb, vom publica un port deschis la Firebase sub cheia serverului și vom asculta evenimentele din subarborele său.

  • Apeluri pe server recvfrom(), acceptând pachete care vin de la orice gazdă de pe acest port.

În cazul nostru, trebuie să verificăm coada de pachete trimise către acest port.

Fiecare port are propria sa coadă și adăugăm porturile sursă și destinație la începutul datagramelor WebRTC, astfel încât să știm la ce coadă să redirecționăm când sosește un nou pachet.

Apelul este neblocant, deci dacă nu există pachete, pur și simplu returnăm -1 și setăm errno=EWOULDBLOCK.

  • Clientul primește IP-ul și portul serverului prin unele mijloace externe și apelează sendto(). Acesta efectuează și un apel intern. bind(), deci ulterior recvfrom() va primi răspunsul fără a executa în mod explicit bind.

În cazul nostru, clientul primește extern cheia șir și folosește funcția resolve() pentru a obține o adresă IP.

În acest moment, inițiam o strângere de mână WebRTC dacă cei doi colegi nu sunt încă conectați unul la altul. Conexiunile la diferite porturi ale aceluiași peer folosesc același canal de date WebRTC.

Efectuăm și indirect bind()astfel încât serverul să se poată reconecta în următorul sendto() în cazul în care s-a închis dintr-un motiv oarecare.

Serverul este notificat cu privire la conexiunea clientului atunci când clientul își scrie oferta SDP sub informațiile portului serverului din Firebase, iar serverul răspunde cu răspunsul său acolo.

Diagrama de mai jos prezintă un exemplu de flux de mesaje pentru o schemă de socket și transmiterea primului mesaj de la client la server:

Portarea unui joc multiplayer din C++ pe web cu Cheerp, WebRTC și Firebase
Schema completă a fazei de conectare dintre client și server

Concluzie

Dacă ați citit până aici, probabil că sunteți interesat să vedeți teoria în acțiune. Jocul poate fi jucat teeworlds.leaningtech.com, incearca-l!


Meci amical între colegi

Codul bibliotecii de rețea este disponibil gratuit la Github. Alăturați-vă conversației de pe canalul nostru la Gitter!

Sursa: www.habr.com

Adauga un comentariu