Prenošenje igre za više igrača sa C++ na web uz Cheerp, WebRTC i Firebase

Uvod

naša kompanija Leaning Technologies pruža rješenja za prijenos tradicionalnih desktop aplikacija na web. Naš C++ kompajler cheerp generiše kombinaciju WebAssembly-a i JavaScript-a, koji pruža i laka interakcija u pretraživačui visoke performanse.

Kao primjer njegove upotrebe, odlučili smo portirati igru ​​za više igrača za web i odabrali Teeworlds. Teeworlds je retro XNUMXD igra za više igrača s malom, ali aktivnom zajednicom igrača (uključujući i mene!). Mali je u smislu resursa za preuzimanje i CPU i GPU zahtjeva - idealan kandidat.

Prenošenje igre za više igrača sa C++ na web uz Cheerp, WebRTC i Firebase
Pokreće se u Teeworlds pretraživaču

Odlučili smo iskoristiti ovaj projekt za eksperimentiranje opća rješenja za prijenos mrežnog koda na web. To se obično radi na sljedeće načine:

  • XMLHttpRequest/fetch, ako se mrežni dio sastoji samo od HTTP zahtjeva, ili
  • web utičnice.

Oba rješenja zahtijevaju hostovanje serverske komponente na strani servera, a nijedno ne dozvoljava da se koristi kao transportni protokol. UDP. Ovo je važno za aplikacije u realnom vremenu kao što su softver za video konferencije i igre jer isporuka paketa i narudžbina protokola garantuju TCP može ometati nisku latenciju.

Postoji i treći način - da koristite mrežu iz pretraživača: WebRTC.

RTCDataChannel podržava pouzdan i nepouzdan prenos (u poslednjem slučaju pokušava da koristi UDP kao transportni protokol kad god je to moguće), i može se koristiti i sa udaljenim serverom i između pretraživača. To znači da možemo prenijeti cijelu aplikaciju u pretraživač, uključujući serversku komponentu!

Međutim, ovo dolazi s dodatnom poteškoćom: prije nego što dva WebRTC vršnjaka mogu komunicirati, moraju izvršiti relativno složeno rukovanje da bi se povezali, što zahtijeva više entiteta treće strane (server za signalizaciju i jedan ili više STUN/TURN).

U idealnom slučaju, željeli bismo da kreiramo mrežni API koji interno koristi WebRTC, ali je što je moguće bliži interfejsu UDP soketa, koji ne mora da uspostavlja vezu.

Ovo će nam omogućiti da iskoristimo prednosti WebRTC-a bez potrebe da izlažemo složene detalje kodu aplikacije (koji smo htjeli što manje mijenjati u našem projektu).

Minimalni WebRTC

WebRTC je skup API-ja dostupnih u pretraživačima za peer-to-peer prijenos audio, video i proizvoljnih podataka.

Veza između vršnjaka se uspostavlja (čak i ako postoji NAT na jednoj ili obje strane) korištenjem STUN i/ili TURN servera kroz mehanizam koji se zove ICE. Peers razmjenjuju ICE informacije i kanalne parametre kroz ponudu i odgovor SDP protokola.

Vau! Koliko skraćenica odjednom. Hajde da ukratko objasnimo šta znače ovi pojmovi:

  • Uslužni programi za prelazak sesije za NAT (STUN) - protokol za zaobilaženje NAT-a i dobijanje para (IP, port) za direktnu komunikaciju sa hostom. Ako uspije da izvrši svoj zadatak, onda vršnjaci mogu samostalno međusobno razmjenjivati ​​podatke.
  • Prelazak pomoću releja oko NAT-a (TURN) se također koristi za zaobilaženje NAT-a, ali to radi prosljeđivanjem podataka preko proxy servera koji je vidljiv za oba ravnopravna korisnika. Dodaje kašnjenje i skuplji je za izvođenje od STUN-a (jer se primjenjuje tokom cijele sesije), ali ponekad je to jedina opcija.
  • Uspostavljanje interaktivnog povezivanja (ICE) koristi se za odabir najboljeg mogućeg načina za povezivanje dva ravnopravna korisnika na osnovu informacija dobijenih direktnim povezivanjem peer-a, kao i informacija koje prima bilo koji broj STUN i TURN servera.
  • Protokol opisa sesije (SDP) - ovo je format za opisivanje parametara kanala veze, na primjer, ICE kandidata, multimedijalnih kodeka (u slučaju audio/video kanala) itd. Jedan od vršnjaka šalje SDP ponudu („ponuda“), a drugi odgovara SDP odgovorom (“odgovor”). Nakon toga se kreira kanal.

Da bi stvorili takvu vezu, vršnjaci moraju prikupiti informacije koje primaju od STUN i TURN servera i međusobno ih razmijeniti.

Problem je u tome što oni još nemaju mogućnost direktne razmjene podataka, tako da mora postojati mehanizam van opsega za razmjenu ovih podataka: signalni server.

Server za signalizaciju može biti vrlo jednostavan, jer njegov jedini zadatak je prosljeđivanje podataka između vršnjaka tokom faze "rukovanja" (kao što je prikazano na dijagramu ispod).

Prenošenje igre za više igrača sa C++ na web uz Cheerp, WebRTC i Firebase
Pojednostavljena WebRTC sekvenca rukovanja

Pregled Teeworlds mrežnog modela

Mrežna arhitektura Teeworldsa je vrlo jednostavna:

  • Komponente klijenta i servera su dva različita programa.
  • Klijenti ulaze u igru ​​povezujući se na jedan od nekoliko servera, od kojih svaki hostuje samo jednu igru.
  • Sav prijenos podataka u igri se odvija preko servera.
  • Poseban glavni server se koristi za prikupljanje liste svih javnih servera koji su prikazani u klijentu igre.

Zahvaljujući korištenju WebRTC-a za razmjenu podataka, serversku komponentu igre možemo prenijeti u pretraživač u kojem se klijent nalazi. Ovo nam pruža odličnu priliku...

Riješite se servera

Odsustvo logike na strani servera ima lijepu prednost: možemo implementirati cijelu aplikaciju kao statički sadržaj na Github stranicama ili na našem vlastitom hardveru iza Cloudflarea, čime se osiguravaju brza preuzimanja i besplatno vrijeme rada. Zapravo, na njih će biti moguće zaboraviti, a ako budemo imali sreće i igra postane popularna, onda infrastruktura neće morati da se nadograđuje.

Međutim, da bi sistem radio, još uvijek moramo koristiti eksternu arhitekturu:

  • Jedan ili više STUN servera: Imamo nekoliko besplatnih opcija koje možete izabrati.
  • Najmanje jedan TURN server: ovdje nema besplatnih opcija, tako da možemo ili postaviti svoj ili platiti uslugu. Srećom, većinu vremena će biti moguće povezati se preko STUN servera (i pružiti pravi p2p), ali TURN je potreban kao rezervni.
  • Server za signalizaciju: Za razliku od druga dva aspekta, signalizacija nije standardizovana. Za šta će signalni server zapravo biti odgovoran zavisi donekle od aplikacije. U našem slučaju, prije uspostavljanja veze, potrebno je razmijeniti malu količinu podataka.
  • Teeworlds glavni server: koriste ga drugi serveri da objave njegovo postojanje i klijenti da pronađu javne servere. Iako nije potrebno (klijenti se uvijek mogu povezati na server koji poznaju ručno), bilo bi lijepo imati ga kako bi igrači mogli igrati igre sa slučajnim ljudima.

Odlučili smo da koristimo Google-ove besplatne STUN servere i sami smo postavili jedan TURN server.

Za posljednje dvije stavke smo koristili Firebase:

  • Teeworlds master server je implementiran vrlo jednostavno: kao lista objekata koji sadrže informacije (ime, IP, mapa, način rada,...) svakog aktivnog servera. Serveri objavljuju i ažuriraju svoj vlastiti objekt, a klijenti preuzimaju cijelu listu i prikazuju je igraču. Takođe prikazujemo listu kao HTML na početnoj stranici tako da igrači mogu jednostavno kliknuti na server i uskočiti pravo u igru.
  • Signalizacija je usko povezana s našom implementacijom utičnice, opisanom u sljedećem odjeljku.

Prenošenje igre za više igrača sa C++ na web uz Cheerp, WebRTC i Firebase
Spisak servera unutar igre i na početnoj stranici

Implementacija utičnice

Želimo kreirati API koji je što je moguće bliži Posix UDP utičnicama kako bismo minimizirali broj potrebnih promjena.

Također želimo implementirati neophodan minimum potreban za najjednostavniju razmjenu podataka preko mreže.

Na primjer, nije nam potrebno pravo rutiranje: svi ravnopravni korisnici su na istom "virtuelnom LAN-u" povezanom s određenom instancom Firebase baze podataka.

Stoga nam nisu potrebne jedinstvene IP adrese: za jedinstvenu identifikaciju kolega dovoljno je koristiti jedinstvene vrijednosti Firebase ključeva (slično imenima domena), a svaki peer lokalno dodjeljuje "lažne" IP adrese svakom ključu koji treba biti prevedeno. Ovo nas potpuno štedi od potrebe da globalno dodjeljujemo IP adrese, što nije trivijalan zadatak.

Evo minimalnog API-ja koji trebamo 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 jednostavan i sličan Posix Sockets API-ju, ali s nekoliko važnih razlika: evidencija povratnog poziva, lokalna dodjela IP-a i lijena veza.

Registriranje povratnih poziva

Čak i ako originalni program koristi neblokirajući I/O, kod mora biti refaktoriran da bi se pokrenuo u web pretraživaču.

Razlog za to je taj što je petlja događaja u pretraživaču skrivena od programa (bilo da je JavaScript ili WebAssembly).

U prirodnom okruženju možemo napisati ovakav kod

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

Ako je petlja događaja skrivena za nas, onda je trebamo pretvoriti u nešto poput ovoga:

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 IP dodjela

ID-ovi čvorova u našoj "mreži" nisu IP adrese, već Firebase ključevi (ovo su nizovi koji izgledaju ovako: -LmEC50PYZLCiCP-vqde ).

Ovo je zgodno jer nam nije potreban mehanizam za dodjelu IP-ova i provjeru da li su jedinstveni (i da ih odbacimo nakon što se klijent prekine), ali je često potrebno identificirati ravnopravne korisnike numeričkom vrijednošću.

Tome služe funkcije. resolve и reverseResolve: Aplikacija nekako prima string vrijednost ključa (preko korisničkog unosa ili preko glavnog servera) i može je razriješiti na IP adresu za internu upotrebu. Ostatak API-ja također prima ovu vrijednost umjesto stringa radi jednostavnosti.

Ovo je slično DNS traženju, samo lokalno na klijentu.

Odnosno, IP adrese se ne mogu dijeliti između različitih klijenata, a ako je potrebna neka vrsta globalnog identifikatora, onda će se morati generirati na drugačiji način.

Lazy join

UDP-u nije potrebna veza, ali kao što smo vidjeli, WebRTC zahtijeva dugotrajan proces povezivanja prije nego što može započeti prijenos podataka između dva ravnopravna korisnika.

Ako želimo pružiti isti nivo apstrakcije, (sendto/recvfrom sa proizvoljnim vršnjacima bez prethodnog povezivanja), tada moraju izvršiti "lijenu" (odloženu) vezu unutar API-ja.

Evo šta se dešava u normalnoj komunikaciji između "servera" i "klijenta" u slučaju korišćenja UDP-a i šta naša biblioteka treba da uradi:

  • Pozivi servera bind()da kaže operativnom sistemu da želi da prima pakete na navedeni port.

Umjesto toga, mi ćemo objaviti otvoreni port za Firebase pod ključem servera i slušati događaje u njegovom podstablu.

  • Pozivi servera recvfrom(), prihvatanje paketa sa bilo kog hosta na ovom portu.

U našem slučaju, moramo provjeriti dolazni red paketa koji se šalju na ovaj port.

Svaki port ima svoj vlastiti red, a mi dodajemo izvorne i odredišne ​​portove na početak WebRTC datagrama tako da znamo na koji red da se preusmjerimo kada stigne novi paket.

Poziv nije blokirajući, tako da ako nema paketa, jednostavno vraćamo -1 i postavljamo errno=EWOULDBLOCK.

  • Klijent na neki eksterni način prima IP i port servera i poziva sendto(). Takođe vrši interni poziv bind(), pa sljedeći recvfrom() će primiti odgovor bez eksplicitnog izvršavanja vezivanja.

U našem slučaju, klijent eksterno prima string ključ i koristi funkciju resolve() da dobijete IP adresu.

U ovom trenutku počinjemo s rukovanjem WebRTC-a ako dva ravnopravna uređaja već nisu međusobno povezana. Veze na različite portove istog peer-a koriste isti WebRTC DataChannel.

Takođe radimo indirektno bind()tako da se server može ponovo povezati sendto() u slučaju da se zatvori iz nekog razloga.

Server je obaviješten o vezi klijenta kada klijent upiše svoju SDP ponudu pod informacijom o portu servera u Firebase-u, a server također tamo odgovara svojim odgovorom.

Dijagram ispod pokazuje primjer toka poruka za šemu utičnice i prvu poruku od klijenta do servera:

Prenošenje igre za više igrača sa C++ na web uz Cheerp, WebRTC i Firebase
Kompletan dijagram faze povezivanja između klijenta i servera

zaključak

Ako ste čitali do sada, možda ćete biti zainteresirani da vidite teoriju na djelu. Igra se može igrati na teeworlds.leaningtech.com, probaj!


Prijateljska utakmica između kolega

Šifra mrežne biblioteke je besplatno dostupna na adresi GitHub. Pridružite se razgovoru na našem kanalu rešetka!

izvor: www.habr.com

Dodajte komentar