Átkonvertáljuk a többjátékos játékot C++-ról webre a Cheerp, WebRTC és Firebase segítségével

Bevezetés

Cégünk Leaning Technologies megoldásokat kínál a hagyományos asztali alkalmazások webre történő átültetésére. C++ fordítónk Cheerp WebAssembly és JavaScript kombinációját generálja, ami biztosítja a könnyű böngészőbeli interakciót, és a nagy teljesítményt.

Példaként arra, hogy mire használható, úgy döntöttünk, hogy átportálunk egy többjátékos játékot a webre, és ezt kiválasztottuk Teeworlds. A Teeworlds egy többjátékos, kétdimenziós retro játék, amelynek kicsi, de aktív játékos közössége van (köztük én is!). Letöltési forrásai és CPU-, GPU-igényei alacsonyak — ideális jelölt.

Átkonvertáljuk a többjátékos játékot C++-ról webre a Cheerp, WebRTC és Firebase segítségével
A böngészőben futó Teeworlds

E projektet úgy döntöttük, hogy arra használjuk, hogy kísérletezünk a webre történő hálózati kód portálására vonatkozó általános megoldásokkal. Ezt általában a következő módokon valósítják meg:

  • XMLHttpRequest/fetch, ha a hálózati rész csak HTTP-kérésekből áll, vagy
  • WebSockets.

Mindkét megoldás megköveteli egy szerveroldali komponens hosztolását, és egyik sem teszi lehetővé, hogy szállítási protokollként használjuk UDP. Ez fontos az olyan valós idejű alkalmazásoknál, mint a videokonferenciák és játékok, mert a megbízhatóság és a csomagok sorrendjének garantálása TCP zavarokat okozhat az alacsony késleltetésben.

Van egy harmadik út is — a böngészőn keresztüli hálózat használata: WebRTC.

RTCDataChannel támogatja a megbízható és a megbízhatatlan adatátvitelt (az utóbbi esetben lehetőség szerint UDP-t próbál használni szállítási protokollként), és alkalmazható távoli szerverrel és böngészők között is. Ez azt jelenti, hogy a teljes alkalmazást átportálhatjuk a böngészőbe, beleértve a szerveroldali komponenst is!

Ez azonban egy további nehézséggel jár: mielőtt a két WebRTC peer adatokat tudna cserélni, viszonylag bonyolult "kézfogó" (handshake) eljárást kell végrehajtani a csatlakozáshoz, amihez több külső entitás (jelző szerver és egy vagy több STUN szerver) szükséges. TURN/Ideálisan olyan hálózati API-t szeretnénk létrehozni, amely belsőleg WebRTC-t használ, de a lehető legjobban hasonlít az UDP Sockets interfészéhez, amely nem igényel kapcsolatot.).

Ideális esetben szeretnénk egy hálózati API-t létrehozni, amely a WebRTC-t használja, de minél közelebb áll a UDP Sockets interfészhez, amelyhez nem szükséges a kapcsolat létrehozása.

Ez lehetővé teszi számunkra, hogy kihasználjuk a WebRTC előnyeit anélkül, hogy bonyolult részleteket kellene felfednünk az alkalmazás kódjáról (amelyet a projektünkben a lehető legkevesebbet akartunk módosítani).

Minimális WebRTC

A WebRTC egy böngészőkben elérhető API-készlet, amely lehetővé teszi a peer-to-peer hang, videó és tetszőleges adatok átvitelét.

A kapcsolatok a peer-ek között STUN és/vagy TURN szervereken keresztül, egy ICE néven ismert mechanizmus segítségével jönnek létre (még akkor is, ha NAT van jelen az egyik vagy mindkét oldalon). A peer-ek ICE információt és csatornaparamétereket cserélnek az SDP protokoll offer és answer mechanizmusán keresztül.

Hűha! Milyen sok rövidítés egy füst alatt. Röviden magyarázzuk el, mit jelentenek ezek a fogalmak:

Ahhoz, hogy ilyen kapcsolatot hozzanak létre, a peer-eknek össze kell gyűjteniük az általuk a STUN és TURN szerverektől kapott információt, és meg kell osztaniuk egymással.

A probléma az, hogy jelenleg nincs közvetlen lehetőségük az adatok cseréjére, ezért egy alternatív mechanizmusnak kell léteznie az adatok átvitelére: a jelző szerver.

A jelző szerver nagyon egyszerű lehet, mert egyetlen feladata az adatok átkapcsolása a peer-ek között az 'ütközés' szakaszban (ahogy az az alábbi ábrán is látható).

Átkonvertáljuk a többjátékos játékot C++-ról webre a Cheerp, WebRTC és Firebase segítségével
Egyszerűsített WebRTC kézfogó folyamatábra

Teeworlds hálózati modell áttekintése

A Teeworlds hálózati architektúrája nagyon egyszerű:

  • A kliens és a szerver komponensek két külön programot jelentenek.
  • A kliensek úgy lépnek be a játékba, hogy csatlakoznak az egyik több szerverhez, amelyek egyszerre csak egy játékot hosztolnak.
  • Minden adatátvitel a játék során a szerveren keresztül történik.
  • Egy külön mester szerver használatával gyűjtjük össze az összes nyilvános szerver listáját, amely a játékkliensben megjelenik.

A WebRTC adatváltásának köszönhetően áthelyezhetjük a játék szerver komponensét a böngészőbe, ahol a kliens található. Ez nagyszerű lehetőséget ad nekünk…

Elhagyjuk a szervereket

A szerverlogika hiányának van egy kellemes előnye: az egész alkalmazást statikus tartalomként telepíthetjük a Github Pages-re vagy a saját berendezésünkre a Cloudflare-en, így ingyenesen biztosíthatjuk a gyors töltést és a magas rendelkezésre állást. Alapvetően elfelejthetjük a szervereket, és ha szerencsénk van, hogy a játék népszerű lesz, akkor az infrastruktúrát nem kell frissíteni.

Azonban ahhoz, hogy a rendszer működjön, még mindig szükségünk van külső architektúrára:

  • Egy vagy több STUN szerver: több ingyenes lehetőség közül választhatunk.
  • Legalább egy TURN szerver: itt nincsenek ingyenes lehetőségek, így sajátot üzemeltethetünk, vagy fizethetünk a szolgáltatásért. Szerencsére a legtöbb időben a kapcsolatot STUN szervereken keresztül lehet létrehozni (és biztosítani az igazi p2p-t), de a TURN mint tartalék lehetőség szükséges.
  • Jelző szerver: a két másik aspektustól eltérően a jelzés nem standardizált. Az, hogy a jelző szerver valójában milyen feladatokat lát el, részben az alkalmazástól függ. A mi esetünkben a kapcsolat létrehozása előtt kis mennyiségű adatcserére van szükség.
  • Teeworlds mester szerver: más szerverek használják arra, hogy értesítsenek a létezésükről, míg a kliensek a nyilvános szerverek keresésére használják. Bár nem kötelező (a kliensek mindig csatlakozhatnak a már ismert szerverhez manuálisan), jó lenne, ha rendelkezésre állna, hogy a játékosok véletlenszerű emberekkel játszhassanak.

Úgy döntöttünk, hogy a Google ingyenes STUN szervereit használjuk, míg egy TURN szervert saját magunk üzemeltetjük.

Az utolsó két ponthoz használtuk Firebase:

  • A Teeworlds mester szervere nagyon egyszerűen van megvalósítva: egy objektumok listájaként, amelyek tartalmazzák az aktív szerverek információit (név, IP, térkép, mód, …). A szerverek közzéteszik és frissítik a saját objektumukat, míg az ügyfelek lekérik az egész listát, és megjelenítik azt a játékosnak. Ezen kívül HTML formájában is megjelenítjük a listát a főoldalon, hogy a játékosok egyszerűen rákattinthassanak a szerverre és közvetlenül belépjenek a játékba.
  • A jelzés szorosan összefügg a következő részben bemutatott socket megvalósításunkkal.

Átkonvertáljuk a többjátékos játékot C++-ról webre a Cheerp, WebRTC és Firebase segítségével
A játékbeli és a főoldalon lévő szerverek listája

Socket megvalósítás

Egy olyan API-t szeretnénk létrehozni, amely a lehető legközelebb áll a Posix UDP Sockets-hoz, hogy minimalizáljuk a szükséges változtatások számát.

A legszükségesebb minimális funkciókat szeretnénk megvalósítani a legegyszerűbb hálózati adatcseréhez.

Például nincs szükségünk valódi útválasztásra: minden peer egy virtuális "LAN"-ban található, amely egy adott Firebase adatbázishoz kapcsolódik.

Ennek megfelelően nincs szükségünk egyedi IP-címekre: a peer-ek egyedi azonosításához elegendő, ha a Firebase egyedi kulcsértékeit használjuk (használható domainnevek szerint), és minden peer helyben "ál" IP-címeket rendel a megfelelő kulcshoz, amelyet le kell fordítani. Ez teljesen megszünteti a globális IP-címek kiosztásának szükségességét, ami nem triviális feladat.

Itt van a minimális API, amelyet meg kell valósítanunk:

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

Az API egyszerű és hasonlít a Posix Sockets API-hoz, de van néhány fontos eltérése: visszahívások regisztrálása, helyi IP-k kiosztása és "lusta" csatlakoztatás.

Visszahívások regisztrálása

Még ha az alapprogram nem blokkoló bemeneti-kimenetet használ is, a webböngészőben való futtatáshoz a kódot refaktorálni kell.

Ennek az az oka, hogy az eseményciklus a böngészőben rejtve van a program elől (legyen szó JavaScript-ről vagy WebAssembly-ről).

Natív környezetben ilyen módon írhatunk kódot

while(running) {
  select(...); // várakozás I/O eseményekre
  while(true) {
    int r = readfrom(...); // próbálkozás az olvasással
    if (r < 0 && errno == EWOULDBLOCK) // nincs több elérhető adat
      break;
    ...
  }
  ...
}

Ha az eseményciklus rejtve van előttünk, akkor azt ilyesmivé kell alakítanunk:

auto cb = []() { // ezt hívják meg, amikor új adatok érhetők el
  while(true) {
    int r = readfrom(...); // próbálkozás az olvasással
    if (r < 0 && errno == EWOULDBLOCK) // nincs több elérhető adat
      break;
    ...
  }
  ...
};
recvCallback(cb); // a visszahívás regisztrálása

Helyi IP-k feladata

A csomópontok az «hálózatunkban» nem IP-címek, hanem Firebase kulcsok (ezek olyan karakterláncok, amelyek így néznek ki: -LmEC50PYZLCiCP-vqde ).

Ez kényelmes, mert nincs szükségünk IP-k kiosztásának mechanizmusára és azok egyediségének ellenőrzésére (valamint azok eltávolítására az ügyfél lekapcsolása után), de gyakran szükséges a peer-ek azonosítása numerikus érték alapján.

Ezért használjuk a következő funkciókat resolve és reverseResolve: az alkalmazás valamilyen módon megszerzi a kulcs string értékét (felhasználói bevitel vagy mesterszerver útján), és át tudja alakítani IP-címmé a belső használat érdekében. Az API többi része is egyszerűség kedvéért ezt az értéket kapja újra, nem string formájában.

Ez hasonlít a DNS keresésre, csak a kliensnél helyben történik.

Tehát az IP-címek nem lehetnek közösek különböző kliensek között, és ha valamilyen globális azonosítóra van szükség, azt más módon kell generálni.

Lusta kapcsolat

Az UDP-nek nincs szüksége kapcsolatra, de mint láttuk, mielőtt adatokat elkezdenénk átadni két peer között, a WebRTC hosszú kapcsolati folyamatot igényel.

Ha ugyanazt az absztrakciós szintet szeretnénk biztosítani, (sendto/recvfrom tetszőleges peerekkel előzetes kapcsolódás nélkül), akkor a belső API-ban «lusta» (késleltetett) kapcsolatot kell létrehoznunk.

Itt van, mi történik az adatcserénél a «szerver» és a «kliensek» között UDP használatakor, és mit kell megvalósítania a könyvtárunknak:

  • A szerver meghívja bind(), hogy értesítse az operációs rendszert, hogy csomagokat szeretne fogadni a megadott porton.

Ehelyett a Firebase-en közzéteszünk egy nyitott portot a szerver kulcsaként, és figyeljük az eseményeket annak a lefelé mutató ágain.

  • A szerver meghívja recvfrom(), fogadva a portjára érkező csomagokat bármely hostból.

A mi esetünkben ellenőriznünk kell a bejövő csomagok sorát, amelyeket erre a portra küldtek.

Minden port saját sorral rendelkezik, és a WebRTC datagramjaként hozzáadjuk a forrást és a célportot, hogy tudjuk, hová irányítsuk az érkező új csomagot.

A hívás nem blokkoló, így ha nincsenek csomagok, egyszerűen -1-et adunk vissza, és beállítjuk errno=EWOULDBLOCK.

  • Az ügyfél valamilyen külső módon megszerzi a szerver IP-címét és portját, és meghívja sendto(). Ezenkívül belső hívás is zajlik, így a következő bind()választ fog kapni a bind explicite végrehajtása nélkül. recvfrom() válasz fog jönni anélkül, hogy a bind kifejezett végrehajtása történne.

Ebben az esetben a kliens külsőleg egy karakterlánc kulcsot kap, és használja a funkciót resolve() az IP-cím megszerzéséhez.

Ebben a szakaszban elkezdjük a WebRTC „kézfogását”, ha a két peer még nem csatlakozott egymáshoz. Különböző portok csatlakozása egy peeren ugyanazt a WebRTC DataChannel-t használja.

Továbbá közvetett bind(), hogy a szerver a következő alkalommal helyre tudja állítani a kapcsolatot, sendto() ha valamilyen okból megszakad.

A szerver értesítést kap a kliens csatlakozásáról, amikor a kliens rögzíti SDP ajánlatát a szerver portinformációjával a Firebase-en, és a szerver ott is válaszol az ajánlatra.

Az alábbi ábrán a socket séma üzeneteinek áramlását és az első üzenet kliensről a szerverre való továbbítását láthatjuk:

Átkonvertáljuk a többjátékos játékot C++-ról webre a Cheerp, WebRTC és Firebase segítségével
A kliens és a szerver közötti csatlakozási szakasz teljes sémája

Zárás

Ha végigolvastad, valószínűleg érdekelne, hogy a gyakorlatban is lásd a teóriát. Játszhatsz a teeworlds.leaningtech.com, próbáld ki!


Barátságos mérkőzés a kollégák között

A hálózati könyvtár kódja szabadon hozzáférhető a Githubot. Csatlakozz a közösséghez a csatornánkon a Gitter!

Forrás: habr.com

Megbízható hosting vásárlása weboldalak számára, DDoS védelemmel, VPS VDS szerverek. 🔥 Megbízható webtárhely vásárlása DDoS védelemmel, VPS VDS szerverek | ProHoster