Kelių žaidėjų žaidimo perkėlimas iš C++ į žiniatinklį su Cheerp, WebRTC ir Firebase

įvedimas

mūsų kompanija „Leaning“ technologijos teikia sprendimus tradicinėms darbalaukio programoms perkelti į internetą. Mūsų C++ kompiliatorius nudžiuginti generuoja WebAssembly ir JavaScript derinį, kuris suteikia ir paprasta naršyklės sąveika, ir didelis našumas.

Kaip jo taikymo pavyzdį nusprendėme kelių žaidėjų žaidimą perkelti į internetą ir pasirinkome Teeworlds. Teeworlds yra kelių žaidėjų XNUMXD retro žaidimas su maža, bet aktyvia žaidėjų bendruomene (įskaitant mane!). Jis yra mažas tiek atsisiųstų išteklių, tiek procesoriaus ir GPU reikalavimų atžvilgiu – idealus kandidatas.

Kelių žaidėjų žaidimo perkėlimas iš C++ į žiniatinklį su Cheerp, WebRTC ir Firebase
Veikia Teeworlds naršyklėje

Mes nusprendėme panaudoti šį projektą eksperimentuodami bendrieji tinklo kodo perkėlimo į internetą sprendimai. Paprastai tai daroma šiais būdais:

  • XMLHttpRequest/fetch, jei tinklo dalį sudaro tik HTTP užklausos, arba
  • WebSockets.

Abu sprendimai reikalauja prieglobos serverio komponento serverio pusėje ir nė vienas neleidžia naudoti kaip transportavimo protokolo UDP. Tai svarbu realaus laiko programoms, tokioms kaip vaizdo konferencijų programinė įranga ir žaidimai, nes tai garantuoja protokolų paketų pristatymą ir tvarką. TCP gali tapti kliūtimi mažam vėlavimui.

Yra trečias būdas - naudokite tinklą iš naršyklės: WebRTC.

RTCDataChannel Jis palaiko tiek patikimą, tiek nepatikimą perdavimą (pastaruoju atveju, kai tik įmanoma, bando naudoti UDP kaip perdavimo protokolą), gali būti naudojamas tiek su nuotoliniu serveriu, tiek tarp naršyklių. Tai reiškia, kad galime perkelti visą programą į naršyklę, įskaitant serverio komponentą!

Tačiau tai sukelia papildomų sunkumų: kad du WebRTC lygiaverčiai partneriai galėtų susisiekti, jie turi atlikti gana sudėtingą rankos paspaudimą, kad galėtų prisijungti, o tam reikia kelių trečiųjų šalių objektų (signalizacijos serverio ir vieno ar daugiau serverių). STUDINIS/SUKTI).

Idealiu atveju norėtume sukurti tinklo API, kuri viduje naudotų WebRTC, bet būtų kuo arčiau UDP Sockets sąsajos, kuriai nereikia užmegzti ryšio.

Tai leis mums pasinaudoti WebRTC pranašumais, neatskleidžiant sudėtingos informacijos programos kodui (kurį savo projekte norėjome keisti kuo mažiau).

Minimalus WebRTC

„WebRTC“ yra naršyklėse pasiekiamų API rinkinys, užtikrinantis garso, vaizdo ir savavališkų duomenų perdavimą lygiaverčiu ryšiu.

Ryšys tarp bendraamžių (net jei vienoje ar abiejose pusėse yra NAT) užmezgamas naudojant STUN ir (arba) TURN serverius per mechanizmą, vadinamą ICE. Bendradarbiai keičiasi ICE informacija ir kanalo parametrais naudodamiesi SDP protokolo pasiūlymu ir atsakymu.

Oho! Kiek santrumpų vienu metu? Trumpai paaiškinkime, ką reiškia šie terminai:

  • Session Traversal Utilities, skirtos NAT (STUDINIS) — NAT apėjimo ir poros (IP, prievado) gavimo protokolas, skirtas tiesiogiai keistis duomenimis su pagrindiniu kompiuteriu. Jei jam pavyksta atlikti savo užduotį, bendraamžiai gali savarankiškai keistis duomenimis vieni su kitais.
  • Perėjimas naudojant reles aplink NAT (SUKTI) taip pat naudojamas NAT perėjimui, tačiau tai įgyvendina persiunčiant duomenis per tarpinį serverį, kuris matomas abiem bendraamžiams. Tai padidina delsą ir yra brangiau įdiegti nei STUN (nes jis taikomas per visą ryšio seansą), tačiau kartais tai yra vienintelė galimybė.
  • Interaktyvaus ryšio sukūrimas (P) naudojamas siekiant pasirinkti geriausią įmanomą dviejų bendraamžių sujungimo būdą, remiantis informacija, gauta iš tiesiogiai jungiančių partnerių, taip pat informacija, kurią gauna bet koks STUN ir TURN serverių skaičius.
  • Sesijos aprašymo protokolas (SDP) yra ryšio kanalo parametrų apibūdinimo formatas, pavyzdžiui, ICE kandidatai, daugialypės terpės kodekai (jei yra garso/vaizdo kanalas) ir tt... Vienas iš partnerių siunčia SDP pasiūlymą, o antrasis atsako SDP atsakymu. .. Po to sukuriamas kanalas.

Norėdami sukurti tokį ryšį, bendraamžiai turi rinkti informaciją, kurią gauna iš STUN ir TURN serverių, ir keistis ja tarpusavyje.

Problema ta, kad jie dar neturi galimybės tiesiogiai bendrauti, todėl turi egzistuoti išorinis mechanizmas, skirtas keistis šiais duomenimis: signalizacijos serveris.

Signalizacijos serveris gali būti labai paprastas, nes jo vienintelis uždavinys yra perduoti duomenis tarp bendraamžių rankų paspaudimo fazėje (kaip parodyta toliau pateiktoje diagramoje).

Kelių žaidėjų žaidimo perkėlimas iš C++ į žiniatinklį su Cheerp, WebRTC ir Firebase
Supaprastinta WebRTC rankų paspaudimų sekos diagrama

Teeworlds tinklo modelio apžvalga

Teeworlds tinklo architektūra yra labai paprasta:

  • Kliento ir serverio komponentai yra dvi skirtingos programos.
  • Klientai į žaidimą patenka prisijungę prie vieno iš kelių serverių, kurių kiekvienas vienu metu talpina tik vieną žaidimą.
  • Visas žaidimo duomenų perdavimas atliekamas per serverį.
  • Specialus pagrindinis serveris naudojamas visų viešųjų serverių, rodomų žaidimo kliente, sąrašui rinkti.

Duomenų mainams naudojant WebRTC, mes galime perkelti žaidimo serverio komponentą į naršyklę, kurioje yra klientas. Tai suteikia mums puikią galimybę...

Atsikratykite serverių

Serverio logikos trūkumas turi gerą pranašumą: mes galime įdiegti visą programą kaip statinį turinį Github puslapiuose arba savo aparatinėje įrangoje, esančioje už Cloudflare, taip užtikrindami greitą atsisiuntimą ir ilgą veikimo laiką nemokamai. Tiesą sakant, galime juos pamiršti, o jei pasiseks ir žaidimas išpopuliarės, infrastruktūros modernizuoti nereikės.

Tačiau, kad sistema veiktų, vis tiek turime naudoti išorinę architektūrą:

  • Vienas ar daugiau STUN serverių: galime rinktis iš kelių nemokamų parinkčių.
  • Bent vienas TURN serveris: nemokamų parinkčių čia nėra, todėl galime arba susikurti savo, arba sumokėti už paslaugą. Laimei, dažniausiai ryšį galima užmegzti per STUN serverius (ir užtikrinti tikrą p2p), tačiau TURN reikalingas kaip atsarginė parinktis.
  • Signalizacijos serveris: Skirtingai nuo kitų dviejų aspektų, signalizacija nėra standartizuota. Už ką iš tikrųjų bus atsakingas signalizacijos serveris, šiek tiek priklauso nuo programos. Mūsų atveju, prieš užmezgant ryšį, būtina apsikeisti nedideliu duomenų kiekiu.
  • „Teeworlds Master Server“: jį naudoja kiti serveriai, norėdami reklamuoti savo egzistavimą, o klientai – norėdami rasti viešuosius serverius. Nors tai nėra būtina (klientai visada gali prisijungti prie serverio, apie kurį žino rankiniu būdu), būtų gerai, kad žaidėjai galėtų dalyvauti žaidimuose su atsitiktiniais žmonėmis.

Nusprendėme naudoti nemokamus Google STUN serverius ir patys įdiegėme vieną TURN serverį.

Paskutinius du taškus naudojome "Firebase":

  • Pagrindinis Teeworlds serveris įgyvendinamas labai paprastai: kaip objektų sąrašas, kuriame yra kiekvieno aktyvaus serverio informacija (pavadinimas, IP, žemėlapis, režimas, ...). Serveriai publikuoja ir atnaujina savo objektą, o klientai paima visą sąrašą ir parodo jį grotuvui. Sąrašą pagrindiniame puslapyje taip pat rodome kaip HTML, todėl žaidėjai gali tiesiog spustelėti serverį ir būti nukreipti tiesiai į žaidimą.
  • Signalizavimas yra glaudžiai susijęs su mūsų lizdų diegimu, aprašytu kitame skyriuje.

Kelių žaidėjų žaidimo perkėlimas iš C++ į žiniatinklį su Cheerp, WebRTC ir Firebase
Serverių sąrašas žaidime ir pagrindiniame puslapyje

Lizdų įgyvendinimas

Norime sukurti API, kuri būtų kuo arčiau Posix UDP Sockets, kad sumažintume reikalingų pakeitimų skaičių.

Taip pat norime įgyvendinti būtiną minimumą, reikalingą paprasčiausiam duomenų mainams tinkle.

Pavyzdžiui, mums nereikia tikro maršruto: visi lygiaverčiai įrenginiai yra tame pačiame „virtualiame LAN“, susietame su konkrečiu „Firebase“ duomenų bazės egzemplioriumi.

Todėl mums nereikia unikalių IP adresų: unikalių „Firebase“ raktų reikšmių (panašių į domenų pavadinimus) pakanka, kad būtų galima vienareikšmiškai identifikuoti bendraamžius, o kiekvienas partneris kiekvienam raktui, kurį reikia išversti, vietoje priskiria „netikrus“ IP adresus. Tai visiškai pašalina visuotinio IP adreso priskyrimo poreikį, o tai yra nebanali užduotis.

Čia yra minimali API, kurią turime įdiegti:

// 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 yra paprasta ir panaši į Posix Sockets API, tačiau turi keletą svarbių skirtumų: atgalinių skambučių registravimas, vietinių IP priskyrimas ir tingūs ryšiai.

Atgalinių skambučių registravimas

Net jei pradinė programa naudoja neblokuojančią įvesties / išvesties funkciją, kodas turi būti pertvarkytas, kad būtų paleistas žiniatinklio naršyklėje.

Taip yra dėl to, kad įvykių ciklas naršyklėje yra paslėptas nuo programos (ar tai būtų JavaScript, ar WebAssembly).

Gimtojoje aplinkoje galime rašyti tokį 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;
    ...
  }
  ...
}

Jei įvykio ciklas mums yra paslėptas, turime jį paversti tokiu būdu:

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

Vietinis IP priskyrimas

Mūsų „tinklo“ mazgų ID yra ne IP adresai, o „Firebase“ raktai (tai eilutės, kurios atrodo taip: -LmEC50PYZLCiCP-vqde ).

Tai patogu, nes mums nereikia IP priskyrimo ir jų unikalumo tikrinimo (taip pat ir šalinimo klientui atsijungus) mechanizmo, tačiau dažnai reikia identifikuoti bendraamžius pagal skaitinę reikšmę.

Būtent tam ir naudojamos funkcijos. resolve и reverseResolve: programa kažkokiu būdu gauna rakto eilutės reikšmę (per vartotojo įvestį arba per pagrindinį serverį) ir gali konvertuoti ją į IP adresą vidiniam naudojimui. Likusi API dalis taip pat gauna šią reikšmę, o ne eilutę, kad būtų paprasčiau.

Tai panašu į DNS paiešką, bet atliekama vietoje kliento.

Tai yra, IP adresai negali būti dalijami tarp skirtingų klientų, o jei reikia kažkokio visuotinio identifikatoriaus, jis turės būti sugeneruotas kitu būdu.

Tingus ryšys

UDP nereikia ryšio, bet, kaip matėme, WebRTC reikalauja ilgo prisijungimo proceso, kad galėtų pradėti perduoti duomenis tarp dviejų lygiaverčių programų.

Jei norime užtikrinti tokį patį abstrakcijos lygį, (sendto/recvfrom su savavališkais bendraamžiais be išankstinio ryšio), tada jie turi atlikti „tingų“ (vėluotą) ryšį API viduje.

Štai kas nutinka įprasto ryšio tarp „serverio“ ir „kliento“ metu naudojant UDP, ir ką turėtų daryti mūsų biblioteka:

  • Serverio skambučiai bind()operacinei sistemai pranešti, kad ji nori gauti paketus nurodytu prievadu.

Vietoj to paskelbsime atvirą prievadą į „Firebase“ naudodami serverio raktą ir klausysime įvykių jo pomedyje.

  • Serverio skambučiai recvfrom(), priima paketus iš bet kurio šio prievado pagrindinio kompiuterio.

Mūsų atveju turime patikrinti į šį prievadą siunčiamų paketų eilę.

Kiekvienas prievadas turi savo eilę, o šaltinio ir paskirties prievadus pridedame prie WebRTC datagramų pradžios, kad žinotume, į kurią eilę persiųsti, kai gaunamas naujas paketas.

Skambutis yra neblokuojantis, todėl jei nėra paketų, tiesiog grąžiname -1 ir nustatome errno=EWOULDBLOCK.

  • Klientas kažkokiomis išorinėmis priemonėmis gauna serverio IP ir prievadą bei skambina sendto(). Tai taip pat atlieka vidinį skambutį. bind(), todėl vėliau recvfrom() gaus atsakymą aiškiai nevykdęs susiejimo.

Mūsų atveju klientas iš išorės gauna eilutės raktą ir naudoja funkciją resolve() norėdami gauti IP adresą.

Šiuo metu inicijuojame „WebRTC“ rankos paspaudimą, jei du bendraamžiai dar nėra vienas su kitu sujungti. Ryšiai su skirtingais to paties partnerio prievadais naudoja tą patį „WebRTC DataChannel“.

Atliekame ir netiesioginius bind()kad serveris galėtų vėl prisijungti kitą kartą sendto() jei jis dėl kokių nors priežasčių uždarytas.

Serveriui pranešama apie kliento ryšį, kai klientas įrašo savo SDP pasiūlymą po serverio prievado informacija sistemoje „Firebase“, o serveris ten atsako savo atsakymu.

Toliau pateiktoje diagramoje parodytas pranešimų srauto, skirto lizdo schemai, pavyzdys ir pirmojo pranešimo perdavimas iš kliento į serverį:

Kelių žaidėjų žaidimo perkėlimas iš C++ į žiniatinklį su Cheerp, WebRTC ir Firebase
Pilna kliento ir serverio ryšio fazės schema

išvada

Jei perskaitėte iki šiol, tikriausiai norite pamatyti teoriją praktiškai. Žaidimą galima žaisti toliau teeworlds.leaningtech.com, pabandyk tai!


Draugiškos rungtynės tarp kolegų

Tinklo bibliotekos kodas yra laisvai prieinamas adresu GitHub. Prisijunkite prie pokalbio mūsų kanale adresu Tinklelis!

Šaltinis: www.habr.com

Добавить комментарий