Prijenos igre za više igrača iz C++ na web uz Cheerp, WebRTC i Firebase

Uvod

naša tvrtka Leaning Technologies nudi rješenja za prijenos tradicionalnih desktop aplikacija na web. Naš C++ prevodilac navijati generira kombinaciju WebAssemblyja i JavaScripta, koja pruža oboje jednostavna interakcija preglednika, i visoke performanse.

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

Prijenos igre za više igrača iz C++ na web uz Cheerp, WebRTC i Firebase
Pokreće se u pregledniku Teeworlds

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
  • WebSockets.

Oba rješenja zahtijevaju hosting komponente poslužitelja na strani poslužitelja i nijedno ne dopušta upotrebu kao transportni protokol UDP. Ovo je važno za aplikacije u stvarnom vremenu kao što su softver za video konferencije i igre, jer jamči isporuku i redoslijed paketa protokola TCP može postati prepreka niskoj latenciji.

Postoji i treći način - koristite mrežu iz preglednika: WebRTC.

RTCDataChannel Podržava i pouzdan i nepouzdan prijenos (u drugom slučaju pokušava koristiti UDP kao transportni protokol kad god je to moguće), a može se koristiti i s udaljenim poslužiteljem i između preglednika. To znači da cijelu aplikaciju možemo prenijeti na preglednik, uključujući komponentu poslužitelja!

Međutim, to dolazi s dodatnom poteškoćom: prije nego što dva WebRTC peera mogu komunicirati, moraju izvršiti relativno složeno rukovanje za povezivanje, što zahtijeva nekoliko entiteta treće strane (poslužitelj za signaliziranje i jedan ili više poslužitelja ONESVIJESTITI/SKRETANJE).

U idealnom slučaju, željeli bismo stvoriti mrežni API koji interno koristi WebRTC, ali je što bliži sučelju UDP Sockets koje ne treba uspostavljati vezu.

To će nam omogućiti da iskoristimo prednosti WebRTC-a bez potrebe za izlaganjem složenih detalja kodu aplikacije (koji smo željeli promijeniti što je manje moguće u našem projektu).

Minimalni WebRTC

WebRTC je skup API-ja dostupnih u preglednicima koji omogućava peer-to-peer prijenos audio, video i proizvoljnih podataka.

Veza između peerova se uspostavlja (čak i ako postoji NAT na jednoj ili obje strane) pomoću STUN i/ili TURN poslužitelja kroz mehanizam koji se zove ICE. Ravni razmjenjuju ICE informacije i parametre kanala putem ponude i odgovora SDP protokola.

Wow! Koliko skraćenica odjednom? Objasnimo ukratko što ovi pojmovi znače:

  • Uslužni programi za prelazak sesije za NAT (ONESVIJESTITI) — protokol za zaobilaženje NAT-a i dobivanje para (IP, port) za razmjenu podataka izravno s hostom. Ako uspije izvršiti svoj zadatak, tada vršnjaci mogu samostalno međusobno razmjenjivati ​​podatke.
  • Prijelaz korištenjem releja oko NAT-a (SKRETANJE) također se koristi za NAT prolazak, ali to implementira prosljeđivanjem podataka kroz proxy koji je vidljiv obama peerima. Dodaje kašnjenje i skuplji je za implementaciju od STUN-a (jer se primjenjuje tijekom cijele komunikacijske sesije), ali ponekad je to jedina opcija.
  • Interaktivna uspostava povezivanja (ICE) koristi se za odabir najboljeg mogućeg načina povezivanja dva ravnopravna računala na temelju informacija dobivenih od izravnih povezujućih peera, kao i informacija koje prima bilo koji broj STUN i TURN poslužitelja.
  • Protokol opisa sesije (SDP) je format za opisivanje parametara kanala veze, na primjer, ICE kandidata, multimedijskih kodeka (u slučaju audio/video kanala), itd... Jedan od peerova šalje SDP ponudu, a drugi odgovara sa SDP odgovorom . Nakon toga se stvara kanal.

Da bi stvorili takvu vezu, ravnopravni korisnici moraju prikupiti informacije koje dobiju od STUN i TURN poslužitelja i međusobno ih razmijeniti.

Problem je u tome što oni još nemaju mogućnost izravne komunikacije, tako da mora postojati izvanpojasni mehanizam za razmjenu ovih podataka: signalni poslužitelj.

Poslužitelj za signaliziranje može biti vrlo jednostavan jer je njegov jedini posao prosljeđivanje podataka između ravnopravnih uređaja u fazi rukovanja (kao što je prikazano na donjem dijagramu).

Prijenos igre za više igrača iz C++ na web uz Cheerp, WebRTC i Firebase
Pojednostavljeni WebRTC dijagram slijeda rukovanja

Pregled mrežnog modela Teeworlds

Teeworlds mrežna arhitektura je vrlo jednostavna:

  • Komponente klijenta i poslužitelja dva su različita programa.
  • Klijenti ulaze u igru ​​spajanjem na jedan od nekoliko poslužitelja, od kojih svaki ugošćuje samo jednu igru ​​u isto vrijeme.
  • Sav prijenos podataka u igrici odvija se preko poslužitelja.
  • Poseban glavni poslužitelj koristi se za prikupljanje popisa svih javnih poslužitelja koji se prikazuju u klijentu igre.

Zahvaljujući korištenju WebRTC-a za razmjenu podataka, serversku komponentu igre možemo prenijeti u preglednik u kojem se nalazi klijent. To nam daje veliku priliku...

Riješite se poslužitelja

Nedostatak poslužiteljske logike ima lijepu prednost: cijelu aplikaciju možemo implementirati kao statički sadržaj na Github stranicama ili na vlastitom hardveru iza Cloudflarea, čime osiguravamo brzo preuzimanje i dugo vrijeme rada besplatno. Zapravo, možemo zaboraviti na njih, a ako budemo imali sreće i igrica postane popularna, onda se infrastruktura neće morati modernizirati.

Međutim, da bi sustav radio, još uvijek moramo koristiti vanjsku arhitekturu:

  • Jedan ili više STUN poslužitelja: Imamo nekoliko besplatnih opcija za odabir.
  • Najmanje jedan TURN poslužitelj: ovdje nema besplatnih opcija, tako da možemo ili postaviti vlastiti ili platiti uslugu. Srećom, većinu vremena veza se može uspostaviti putem STUN poslužitelja (i pružiti pravi p2p), ali TURN je potreban kao rezervna opcija.
  • Signalni poslužitelj: Za razliku od druga dva aspekta, signalizacija nije standardizirana. Za što će signalni poslužitelj zapravo biti odgovoran ovisi donekle o aplikaciji. U našem slučaju prije uspostavljanja veze potrebno je razmijeniti malu količinu podataka.
  • Glavni poslužitelj Teeworlds: koriste ga drugi poslužitelji za reklamiranje svog postojanja i klijenti za pronalaženje javnih poslužitelja. Iako nije potrebno (klijenti se uvijek mogu ručno spojiti na poslužitelj za koji znaju), bilo bi lijepo imati kako bi igrači mogli sudjelovati u igrama s nasumičnim ljudima.

Odlučili smo koristiti Googleove besplatne STUN poslužitelje i sami smo postavili jedan TURN poslužitelj.

Za posljednje dvije točke koje smo koristili Firebase:

  • Glavni poslužitelj Teeworlds implementiran je vrlo jednostavno: kao lista objekata koji sadrže podatke (ime, IP, mapa, mod, ...) svakog aktivnog poslužitelja. Poslužitelji objavljuju i ažuriraju svoje objekte, a klijenti uzimaju cijeli popis i prikazuju ga igraču. Također prikazujemo popis na početnoj stranici kao HTML tako da igrači mogu jednostavno kliknuti na poslužitelj i biti odvedeni ravno u igru.
  • Signalizacija je usko povezana s našom implementacijom soketa, opisanom u sljedećem odjeljku.

Prijenos igre za više igrača iz C++ na web uz Cheerp, WebRTC i Firebase
Popis poslužitelja unutar igre i na početnoj stranici

Implementacija utičnica

Želimo stvoriti API koji je što bliži Posix UDP utičnicama kako bismo smanjili broj potrebnih promjena.

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

Na primjer, ne trebamo pravo usmjeravanje: svi su ravnopravni uređaji na istom "virtualnom LAN-u" povezanom s određenom instancom Firebase baze podataka.

Stoga nam ne trebaju jedinstvene IP adrese: jedinstvene vrijednosti Firebase ključa (slično imenima domena) dovoljne su za jedinstvenu identifikaciju ravnopravnih računala, a svaki ravnopravan lokalno dodjeljuje "lažne" IP adrese svakom ključu koji treba prevesti. Ovo potpuno eliminira potrebu za globalnom dodjelom IP adrese, što nije trivijalan zadatak.

Ovdje je minimalni API 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 ima nekoliko važnih razlika: bilježenje povratnih poziva, dodjeljivanje lokalnih IP adresa i lijene veze.

Registracija povratnih poziva

Čak i ako izvorni program koristi neblokirajući I/O, kod se mora refaktorirati za izvođenje u web pregledniku.

Razlog tome je taj što je petlja događaja u pregledniku skrivena od programa (bilo da se radi o JavaScriptu ili WebAssemblyju).

U prirodnom okruženju možemo pisati 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 nam je petlja događaja skrivena, onda je moramo 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 (oni su nizovi koji izgledaju ovako: -LmEC50PYZLCiCP-vqde ).

Ovo je zgodno jer nam ne treba mehanizam za dodjelu IP-ova i provjeru njihove jedinstvenosti (kao ni njihovo odbacivanje nakon što se klijent prekine), ali je često potrebno identificirati peere pomoću numeričke vrijednosti.

To je upravo ono za što se funkcije koriste. resolve и reverseResolve: Aplikacija na neki način prima vrijednost niza ključa (preko korisničkog unosa ili putem glavnog poslužitelja) i može je pretvoriti u IP adresu za internu upotrebu. Ostatak API-ja također prima ovu vrijednost umjesto niza radi jednostavnosti.

Ovo je slično DNS traženju, ali se izvodi lokalno na klijentu.

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

Lijena veza

UDP ne treba vezu, ali kao što smo vidjeli, WebRTC zahtijeva dugotrajan proces povezivanja prije nego što može započeti prijenos podataka između dva uređaja.

Ako želimo pružiti istu razinu apstrakcije, (sendto/recvfrom s proizvoljnim vršnjacima bez prethodne veze), tada moraju izvršiti "lijenu" (odgođenu) vezu unutar API-ja.

Ovo se događa tijekom normalne komunikacije između "poslužitelja" i "klijenta" kada se koristi UDP, i što bi naša knjižnica trebala učiniti:

  • Pozivi poslužitelja bind()da kaže operativnom sustavu da želi primati pakete na navedeni port.

Umjesto toga, objavit ćemo otvoreni priključak za Firebase pod ključem poslužitelja i osluškivati ​​događaje u njegovom podstablu.

  • Pozivi poslužitelja recvfrom(), prihvaćajući pakete koji dolaze s bilo kojeg 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 čekanja, a izvorni i odredišni port dodajemo na početak WebRTC datagrama kako bismo znali u koji red čekanja proslijediti kada stigne novi paket.

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

  • Klijent prima IP i port poslužitelja nekim vanjskim sredstvom i poziva sendto(). Ovo također čini interni poziv. bind(), dakle naknadno recvfrom() će primiti odgovor bez eksplicitnog izvršavanja vezanja.

U našem slučaju, klijent eksterno prima ključ niza i koristi funkciju resolve() za dobivanje IP adrese.

U ovom trenutku pokrećemo WebRTC rukovanje ako dva ravnopravna uređaja još nisu međusobno povezana. Veze na različite priključke istog peera koriste isti WebRTC DataChannel.

Obavljamo i neizravne bind()tako da se poslužitelj može ponovno povezati u sljedećem sendto() u slučaju da se zatvori iz nekog razloga.

Poslužitelj je obaviješten o klijentovoj vezi kada klijent napiše svoju SDP ponudu pod informacijama o priključku poslužitelja u Firebaseu, a poslužitelj tamo odgovara svojim odgovorom.

Donji dijagram prikazuje primjer protoka poruka za shemu utičnice i prijenos prve poruke od klijenta do poslužitelja:

Prijenos igre za više igrača iz C++ na web uz Cheerp, WebRTC i Firebase
Kompletan dijagram faze veze između klijenta i poslužitelja

Zaključak

Ako ste čitali dovde, vjerojatno vas zanima vidjeti teoriju na djelu. Igra se može igrati na teeworlds.leaningtech.com, probaj!


Prijateljska utakmica između kolega

Kod mrežne knjižnice je besplatno dostupan na Github. Pridružite se razgovoru na našem kanalu na Gitter!

Izvor: www.habr.com

Dodajte komentar