Prenos igre za več igralcev iz C++ v splet s Cheerp, WebRTC in Firebase

Predstavitev

naše podjetje Leaning Technologies ponuja rešitve za prenos tradicionalnih namiznih aplikacij v splet. Naš prevajalnik C++ navijati ustvari kombinacijo WebAssembly in JavaScript, ki zagotavlja oboje preprosta interakcija z brskalnikom, in visoko zmogljivost.

Kot primer njegove uporabe smo se odločili prenesti igro za več igralcev v splet in izbrali Teeworlds. Teeworlds je XNUMXD retro igra za več igralcev z majhno, a aktivno skupnostjo igralcev (vključno z mano!). Majhen je tako v smislu prenesenih virov kot glede zahtev CPE in GPE – idealen kandidat.

Prenos igre za več igralcev iz C++ v splet s Cheerp, WebRTC in Firebase
Deluje v brskalniku Teeworlds

Odločili smo se, da bomo ta projekt uporabili za eksperimentiranje splošne rešitve za prenos omrežne kode v splet. To se običajno naredi na naslednje načine:

  • XMLHttpRequest/fetch, če omrežni del sestavljajo samo HTTP zahteve, oz
  • WebSockets.

Obe rešitvi zahtevata gostovanje strežniške komponente na strani strežnika in nobena ne omogoča uporabe kot transportnega protokola UDP. To je pomembno za aplikacije v realnem času, kot so programska oprema za videokonference in igre, saj zagotavlja dostavo in vrstni red paketov protokola TCP lahko postane ovira za nizko zakasnitev.

Obstaja še tretji način - uporabite omrežje iz brskalnika: WebRTC.

RTCDataChannel Podpira tako zanesljiv kot nezanesljiv prenos (v slednjem primeru poskuša uporabiti UDP kot transportni protokol, kadar koli je to mogoče), in se lahko uporablja tako z oddaljenim strežnikom kot med brskalniki. To pomeni, da lahko prenesemo celotno aplikacijo v brskalnik, vključno s strežniško komponento!

Vendar pa je to povezano z dodatno težavo: preden lahko dva vrstnika WebRTC komunicirata, morata izvesti razmeroma zapleteno rokovanje za povezavo, kar zahteva več subjektov tretjih oseb (strežnik za signalizacijo in enega ali več strežnikov ZADNJI/OBRAT).

V idealnem primeru bi radi ustvarili omrežni API, ki interno uporablja WebRTC, vendar je čim bližje vmesniku UDP Sockets, ki mu ni treba vzpostaviti povezave.

To nam bo omogočilo, da izkoristimo WebRTC, ne da bi morali izpostavljati zapletene podrobnosti kodi aplikacije (ki smo jo želeli čim manj spremeniti v našem projektu).

Najmanjši WebRTC

WebRTC je niz API-jev, ki so na voljo v brskalnikih in zagotavljajo enakovredni prenos zvoka, videa in poljubnih podatkov.

Povezava med vrstniki se vzpostavi (tudi če je NAT na eni ali obeh straneh) z uporabo strežnikov STUN in/ali TURN prek mehanizma, imenovanega ICE. Vrstniki izmenjujejo informacije ICE in parametre kanala prek ponudbe in odgovora protokola SDP.

Vau! Koliko okrajšav naenkrat? Naj na kratko razložimo, kaj ti izrazi pomenijo:

  • Pripomočki za prehod seje za NAT (ZADNJI) — protokol za obhod NAT in pridobitev para (IP, vrata) za neposredno izmenjavo podatkov z gostiteljem. Če uspe opraviti svojo nalogo, lahko vrstniki neodvisno izmenjujejo podatke med seboj.
  • Prehod z uporabo relejev okoli NAT (OBRAT) se uporablja tudi za prehod NAT, vendar to izvaja s posredovanjem podatkov prek proxyja, ki je viden obema vrstnikoma. Doda zakasnitev in je dražja za implementacijo kot STUN (ker se uporablja skozi celotno komunikacijsko sejo), vendar je včasih edina možnost.
  • Vzpostavitev interaktivne povezljivosti (ICE) uporablja za izbiro najboljšega možnega načina povezovanja dveh vrstnikov na podlagi informacij, pridobljenih od neposrednih povezovalnih vrstnikov, kot tudi informacij, ki jih prejme poljubno število strežnikov STUN in TURN.
  • Protokol opisa seje (SDP) je format za opis parametrov povezovalnega kanala, na primer ICE kandidatov, multimedijskih kodekov (v primeru avdio/video kanala), itd... Eden od vrstnikov pošlje ponudbo SDP, drugi pa odgovori z odgovorom SDP .. Po tem se ustvari kanal.

Za ustvarjanje takšne povezave morajo vrstniki zbrati informacije, ki jih prejmejo od strežnikov STUN in TURN, ter jih izmenjati med seboj.

Težava je v tem, da še nimajo možnosti neposredne komunikacije, zato mora za izmenjavo teh podatkov obstajati zunajpasovni mehanizem: signalni strežnik.

Strežnik za signaliziranje je lahko zelo preprost, saj je njegova edina naloga posredovanje podatkov med vrstniki v fazi rokovanja (kot je prikazano na spodnjem diagramu).

Prenos igre za več igralcev iz C++ v splet s Cheerp, WebRTC in Firebase
Poenostavljen diagram zaporedja rokovanja WebRTC

Pregled modela omrežja Teeworlds

Arhitektura omrežja Teeworlds je zelo preprosta:

  • Odjemalska in strežniška komponenta sta dva različna programa.
  • Stranke vstopijo v igro tako, da se povežejo z enim od več strežnikov, od katerih vsak gosti samo eno igro hkrati.
  • Ves prenos podatkov v igri poteka prek strežnika.
  • Poseben glavni strežnik se uporablja za zbiranje seznama vseh javnih strežnikov, ki so prikazani v odjemalcu igre.

Zahvaljujoč uporabi WebRTC za izmenjavo podatkov lahko prenesemo strežniško komponento igre v brskalnik, kjer se nahaja odjemalec. To nam daje odlično priložnost...

Znebite se strežnikov

Pomanjkanje strežniške logike ima lepo prednost: celotno aplikacijo lahko postavimo kot statično vsebino na straneh Github ali na lastni strojni opremi za Cloudflare, s čimer zagotovimo hitre prenose in dolgotrajno delovanje brezplačno. Pravzaprav lahko pozabimo nanje in če bomo imeli srečo in bo igra postala popularna, potem infrastrukture ne bo treba posodabljati.

Da pa sistem deluje, moramo še vedno uporabljati zunanjo arhitekturo:

  • Eden ali več strežnikov STUN: Na izbiro imamo več brezplačnih možnosti.
  • Vsaj en strežnik TURN: tukaj ni brezplačnih možnosti, zato lahko postavimo svojega ali plačamo storitev. Na srečo je večino časa mogoče povezavo vzpostaviti prek strežnikov STUN (in zagotoviti pravi p2p), vendar je TURN potreben kot nadomestna možnost.
  • Signalni strežnik: Za razliko od drugih dveh vidikov signalizacija ni standardizirana. Za kaj bo signalizacijski strežnik dejansko odgovoren, je nekoliko odvisno od aplikacije. V našem primeru je treba pred vzpostavitvijo povezave izmenjati manjšo količino podatkov.
  • Glavni strežnik Teeworlds: uporabljajo ga drugi strežniki za oglaševanje svojega obstoja in odjemalci za iskanje javnih strežnikov. Čeprav to ni potrebno (odjemalci se lahko vedno ročno povežejo s strežnikom, ki ga poznajo), bi bilo lepo, da bi igralci lahko sodelovali v igrah z naključnimi ljudmi.

Odločili smo se za uporabo Googlovih brezplačnih strežnikov STUN in sami namestili en strežnik TURN.

Za zadnji dve točki smo uporabili Firebase:

  • Glavni strežnik Teeworlds je implementiran zelo preprosto: kot seznam objektov, ki vsebuje podatke (ime, IP, zemljevid, način, ...) vsakega aktivnega strežnika. Strežniki objavljajo in posodabljajo lastne objekte, odjemalci pa vzamejo celoten seznam in ga prikažejo igralcu. Seznam prikažemo tudi na domači strani kot HTML, tako da lahko igralci preprosto kliknejo na strežnik in se usmerijo naravnost v igro.
  • Signalizacija je tesno povezana z našo implementacijo vtičnic, opisano v naslednjem razdelku.

Prenos igre za več igralcev iz C++ v splet s Cheerp, WebRTC in Firebase
Seznam strežnikov v igri in na domači strani

Izvedba vtičnic

Želimo ustvariti API, ki je čim bližje vtičnicam Posix UDP, da zmanjšamo število potrebnih sprememb.

Želimo tudi implementacijo potrebnega minimuma za čim bolj preprosto izmenjavo podatkov po omrežju.

Na primer, ne potrebujemo pravega usmerjanja: vsi vrstniki so v istem "virtualnem omrežju", povezanem z določeno instanco baze podatkov Firebase.

Zato ne potrebujemo edinstvenih naslovov IP: edinstvene vrednosti ključa Firebase ​​(podobno kot imena domen) zadostujejo za enolično identifikacijo vrstnikov in vsak vrstnik lokalno dodeli "lažne" naslove IP vsakemu ključu, ki ga je treba prevesti. To popolnoma odpravi potrebo po globalni dodelitvi naslova IP, kar ni trivialna naloga.

Tukaj je minimalni API, ki ga moramo implementirati:

// 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 je preprost in podoben API-ju Posix Sockets, vendar ima nekaj pomembnih razlik: beleženje povratnih klicev, dodeljevanje lokalnih IP-jev in lene povezave.

Registracija povratnih klicev

Tudi če izvirni program uporablja neblokirni V/I, je treba kodo preoblikovati za izvajanje v spletnem brskalniku.

Razlog za to je, da je zanka dogodkov v brskalniku skrita pred programom (naj bo to JavaScript ali WebAssembly).

V izvornem okolju lahko pišemo kodo, kot je ta

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

Če nam je zanka dogodkov skrita, jo moramo spremeniti v nekaj takega:

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

Lokalna dodelitev IP-ja

ID-ji vozlišč v našem "omrežju" niso naslovi IP, ampak ključi Firebase (so nizi, ki izgledajo takole: -LmEC50PYZLCiCP-vqde ).

To je priročno, ker ne potrebujemo mehanizma za dodeljevanje IP-jev in preverjanje njihove unikatnosti (kot tudi odstranjevanje le-teh, ko odjemalec prekine povezavo), vendar je pogosto potrebno identificirati vrstnike po številski vrednosti.

Prav za to se te funkcije uporabljajo. resolve и reverseResolve: Aplikacija nekako prejme vrednost niza ključa (prek uporabniškega vnosa ali prek glavnega strežnika) in jo lahko pretvori v naslov IP za interno uporabo. Preostali API zaradi poenostavitve prav tako prejme to vrednost namesto niza.

To je podobno iskanju DNS, vendar se izvaja lokalno na odjemalcu.

To pomeni, da naslovov IP ni mogoče deliti med različnimi odjemalci in če je potreben nekakšen globalni identifikator, ga bo treba ustvariti na drugačen način.

Lena povezava

UDP ne potrebuje povezave, a kot smo videli, WebRTC zahteva dolgotrajen postopek povezovanja, preden lahko začne prenašati podatke med dvema vrstnikoma.

Če želimo zagotoviti enako raven abstrakcije, (sendto/recvfrom s poljubnimi vrstniki brez predhodne povezave), potem morajo izvesti »leno« (zakasnjeno) povezavo znotraj API-ja.

To se zgodi med običajno komunikacijo med »strežnikom« in »odjemalcem« pri uporabi UDP in kaj bi morala narediti naša knjižnica:

  • Klici strežnika bind()da pove operacijskemu sistemu, da želi prejemati pakete na določenih vratih.

Namesto tega bomo objavili odprta vrata za Firebase pod ključem strežnika in poslušali dogodke v njegovem poddrevesu.

  • Klici strežnika recvfrom(), ki sprejema pakete, ki prihajajo iz katerega koli gostitelja na teh vratih.

V našem primeru moramo preveriti dohodno čakalno vrsto paketov, poslanih na ta vrata.

Vsaka vrata imajo svojo čakalno vrsto, izvorna in ciljna vrata pa dodamo na začetek datagramov WebRTC, da vemo, v katero čakalno vrsto posredovati, ko prispe nov paket.

Klic ni blokiran, tako da, če ni paketov, preprosto vrnemo -1 in nastavimo errno=EWOULDBLOCK.

  • Odjemalec prejme IP in vrata strežnika z nekim zunanjim sredstvom in pokliče sendto(). To pomeni tudi notranji klic. bind(), torej naknadno recvfrom() bo prejel odgovor brez izrecne izvedbe povezovanja.

V našem primeru odjemalec od zunaj prejme ključ niza in uporabi funkcijo resolve() za pridobitev naslova IP.

Na tej točki sprožimo rokovanje WebRTC, če vrstnika še nista med seboj povezana. Povezave z različnimi vrati istega vrstnika uporabljajo isti WebRTC DataChannel.

Izvajamo tudi posredno bind()tako da se strežnik lahko znova poveže v naslednjem sendto() če se je iz nekega razloga zaprl.

Strežnik je obveščen o odjemalčevi povezavi, ko odjemalec zapiše svojo ponudbo SDP pod informacije o vratih strežnika v Firebase, strežnik pa se tam odzove s svojim odgovorom.

Spodnji diagram prikazuje primer pretoka sporočil za shemo vtičnic in prenos prvega sporočila od odjemalca do strežnika:

Prenos igre za več igralcev iz C++ v splet s Cheerp, WebRTC in Firebase
Celoten diagram faze povezave med odjemalcem in strežnikom

Zaključek

Če ste prebrali tako daleč, vas verjetno zanima videti teorijo v akciji. Igro je mogoče igrati naprej teeworlds.leaningtech.com, poskusi!


Prijateljska tekma med kolegi

Koda omrežne knjižnice je prosto dostopna na GitHub. Pridružite se pogovoru na našem kanalu na omrežje!

Vir: www.habr.com

Dodaj komentar