Többszereplős játék portolása C++-ról az internetre 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 internetre történő portolására. C++ fordítónk ujjongjon WebAssembly és JavaScript kombinációját hozza létre, amely mindkettőt biztosítja egyszerű böngésző interakció, és nagy teljesítményű.

Alkalmazásának példájaként úgy döntöttünk, hogy egy többjátékos játékot portolunk az internetre, és úgy döntöttünk Teeworlds. A Teeworlds egy többjátékos XNUMXD retro játék kicsi, de aktív játékosközösséggel (köztük én is!). Mind a letöltött erőforrások, mind a CPU és GPU követelmények tekintetében kicsi – ideális jelölt.

Többszereplős játék portolása C++-ról az internetre a Cheerp, WebRTC és Firebase segítségével
A Teeworlds böngészőben fut

Úgy döntöttünk, hogy ezzel a projekttel kísérletezzünk általános megoldások a hálózati kód webre vitelére. Ez általában a következő módokon történik:

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

Mindkét megoldás kiszolgálóoldali szerverkomponenst igényel, és egyik sem teszi lehetővé szállítási protokollként való használatát UDP. Ez fontos a valós idejű alkalmazásoknál, például a videokonferencia szoftvereknél és a játékoknál, mert garantálja a protokollcsomagok kézbesítését és sorrendjét TCP akadályozhatja az alacsony késleltetést.

Van egy harmadik módja is - használja a hálózatot a böngészőből: WebRTC.

RTCDataChannel Támogatja a megbízható és a megbízhatatlan átvitelt egyaránt (utóbbi esetben lehetőség szerint az UDP-t igyekszik átviteli protokollként használni), és távoli szerverrel és böngészők között is használható. Ez azt jelenti, hogy a teljes alkalmazást portolhatjuk a böngészőre, beleértve a szerver komponenst is!

Ez azonban további nehézségekkel jár: mielőtt két WebRTC-társ kommunikálhatna, viszonylag összetett kézfogást kell végrehajtaniuk a csatlakozáshoz, amihez több külső entitás szükséges (egy jelzőszerver és egy vagy több szerver). KÁBÍTÁS/FORDULAT).

Ideális esetben olyan hálózati API-t szeretnénk létrehozni, amely belsőleg használja a WebRTC-t, de a lehető legközelebb áll egy UDP Sockets interfészhez, amelyhez nincs szükség kapcsolat létrehozására.

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

Minimális WebRTC

A WebRTC a böngészőkben elérhető API-készlet, amely hang-, kép- és tetszőleges adatok peer-to-peer átvitelét biztosítja.

A partnerek közötti kapcsolat STUN és/vagy TURN szerverek használatával jön létre (még akkor is, ha az egyik vagy mindkét oldalon NAT van), az ICE nevű mechanizmuson keresztül. A társak ICE információkat és csatornaparamétereket cserélnek az SDP protokoll ajánlatán és válaszán keresztül.

Azta! Hány rövidítés van egyszerre? Röviden magyarázzuk el, mit jelentenek ezek a kifejezések:

  • Session Traversal Utilities for NAT (KÁBÍTÁS) — protokoll a NAT megkerülésére és egy pár (IP, port) beszerzésére a gazdagéppel való közvetlen adatcseréhez. Ha sikerül elvégeznie a feladatát, akkor a társak önállóan cserélhetnek adatokat egymással.
  • Bejárás NAT körüli relék használatával (FORDULAT) NAT bejárásra is használatos, de ezt úgy valósítja meg, hogy az adatokat egy mindkét társ számára látható proxyn keresztül továbbítja. Megnöveli a késleltetést, és költségesebb a megvalósítása, mint a STUN (mivel a teljes kommunikációs munkamenet során alkalmazzák), de néha ez az egyetlen lehetőség.
  • Interaktív összeköttetés létrehozása (ICE) arra szolgál, hogy kiválassza a lehető legjobb módszert a két partner összekapcsolására a közvetlenül csatlakozó partnerektől kapott információk, valamint a tetszőleges számú STUN és TURN szerver által kapott információk alapján.
  • Session Description Protocol (SDP) a kapcsolati csatorna paramétereinek leírására szolgáló formátum, például ICE jelöltek, multimédiás kodekek (audio/videó csatorna esetén), stb... Az egyik partner SDP-ajánlatot küld, a másik pedig SDP-választ válaszol . Ezt követően létrejön egy csatorna.

Egy ilyen kapcsolat létrehozásához a partnereknek össze kell gyűjteniük a STUN és a TURN szerverektől kapott információkat, és ki kell cserélniük egymással.

A probléma az, hogy még nem tudnak közvetlenül kommunikálni, ezért léteznie kell egy sávon kívüli mechanizmusnak az adatok cseréjéhez: egy jelzőszervernek.

A jelzőszerver nagyon egyszerű lehet, mert egyetlen feladata az adatok továbbítása a társak között a „kézfogás” fázisban (ahogy az alábbi ábrán látható).

Többszereplős játék portolása C++-ról az internetre a Cheerp, WebRTC és Firebase segítségével
Egyszerűsített WebRTC kézfogási szekvenciadiagram

Teeworlds hálózati modell áttekintése

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

  • A kliens és a szerver összetevők két különböző program.
  • Az ügyfelek úgy lépnek be a játékba, hogy csatlakoznak a több szerver egyikéhez, amelyek mindegyike egyszerre csak egy játéknak ad otthont.
  • A játékban minden adatátvitel a szerveren keresztül történik.
  • Egy speciális főkiszolgálót használnak a játékkliensben megjelenő nyilvános szerverek listájának összegyűjtésére.

A WebRTC adatcserére való használatának köszönhetően a játék szerver komponensét át tudjuk vinni abba a böngészőbe, ahol a kliens található. Ez nagyszerű lehetőséget ad nekünk...

Megszabadulni a szerverektől

A szerverlogika hiányának van egy szép előnye: a teljes alkalmazást statikus tartalomként telepíthetjük a Github Pages-re, vagy saját hardverünkön a Cloudflare mögött, így biztosítva a gyors letöltéseket és a magas rendelkezésre állást ingyen. Sőt, el is felejthetjük őket, és ha szerencsénk van és a játék népszerűvé válik, akkor nem kell modernizálni az infrastruktúrát.

A rendszer működéséhez azonban továbbra is külső architektúrát kell használnunk:

  • Egy vagy több STUN szerver: Számos ingyenes lehetőség közül választhat.
  • Legalább egy TURN szerver: itt nincs ingyenes lehetőség, így vagy beállíthatjuk a sajátunkat, vagy fizethetünk a szolgáltatásért. Szerencsére a legtöbb esetben a kapcsolat STUN szervereken keresztül létesíthető (és valódi p2p-t biztosítanak), de tartalék lehetőségként a TURN-ra van szükség.
  • Jelzőszerver: A másik két szemponttal ellentétben a jelzés nem szabványosított. Az, hogy a jelzőszerver valójában miért lesz felelős, némileg az alkalmazástól függ. Esetünkben a kapcsolat létrehozása előtt szükséges egy kis adatcsere.
  • Teeworlds Master Server: Más szerverek a létezésük hirdetésére használják, a kliensek pedig arra, hogy nyilvános szervereket találjanak. Bár ez nem kötelező (az ügyfelek mindig manuálisan csatlakozhatnak egy általuk ismert szerverhez), jó lenne, ha a játékosok véletlenszerű emberekkel játszhatnának.

Úgy döntöttünk, hogy a Google ingyenes STUN-szervereit használjuk, és mi magunk telepítettünk egy TURN-szervert.

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

  • A Teeworlds főszerver nagyon egyszerűen van megvalósítva: az egyes aktív szerverek információit (név, IP, térkép, mód, ...) tartalmazó objektumok listája. A szerverek közzéteszik és frissítik saját objektumaikat, a kliensek pedig a teljes listát és megjelenítik a lejátszónak. A listát a kezdőlapon is HTML formátumban jelenítjük meg, így a játékosok egyszerűen rákattinthatnak a szerverre, és közvetlenül a játékba kerülhetnek.
  • A jelzés szorosan kapcsolódik a socket megvalósításunkhoz, amelyet a következő részben ismertetünk.

Többszereplős játék portolása C++-ról az internetre a Cheerp, WebRTC és Firebase segítségével
Szerverek listája a játékon belül és a kezdőlapon

Aljzatok megvalósítása

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

A legegyszerűbb hálózaton keresztüli adatcseréhez szükséges minimumot is szeretnénk megvalósítani.

Például nincs szükségünk valódi útválasztásra: az összes társ ugyanazon a „virtuális LAN-on” van, amely egy adott Firebase adatbázispéldányhoz van társítva.

Ezért nincs szükségünk egyedi IP-címekre: az egyedi Firebase-kulcsértékek (hasonlóan a domainnevekhez) elegendőek a társak egyedi azonosításához, és minden partner helyileg „hamis” IP-címet rendel minden egyes lefordítandó kulcshoz. Ez teljesen kiküszöböli a globális IP-cím hozzárendelésének szükségességét, ami nem triviális feladat.

Íme a minimális API, amelyet megvalósítanunk kell:

// 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ó a Posix Sockets API-hoz, de van néhány fontos különbség: visszahívások naplózása, helyi IP-címek hozzárendelése és lusta kapcsolatok.

Visszahívások regisztrálása

Még akkor is, ha az eredeti program nem blokkoló I/O-t használ, a kódot át kell alakítani a webböngészőben való futtatáshoz.

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

A natív környezetben írhatunk ilyen kódot

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

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

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

Helyi IP hozzárendelés

A "hálózatunkban" lévő csomópont-azonosítók nem IP-címek, hanem Firebase-kulcsok (ezek karakterláncok, amelyek így néznek ki: -LmEC50PYZLCiCP-vqde ).

Ez azért kényelmes, mert nincs szükségünk IP-címek kiosztására és egyediségük ellenőrzésére (valamint a kliens leválasztása utáni selejtezésre), hanem gyakran szükséges számértékkel azonosítani a partnereket.

A funkciók pontosan erre szolgálnak. resolve и reverseResolve: Az alkalmazás valahogy megkapja a kulcs karakterlánc-értékét (felhasználói bemeneten vagy a főszerveren keresztül), és belső használatra képes IP-címmé alakítani. Az API többi része is ezt az értéket kapja meg karakterlánc helyett az egyszerűség kedvéért.

Ez hasonló a DNS-kereséshez, de helyileg, az ügyfélen hajtják végre.

Vagyis az IP-címek nem oszthatók meg különböző kliensek között, és ha valamilyen globális azonosítóra van szükség, akkor azt más módon kell előállítani.

Lusta kapcsolat

Az UDP-nek nincs szüksége kapcsolatra, de amint láttuk, a WebRTC-nek hosszas kapcsolódási folyamatra van szüksége, mielőtt megkezdené az adatátvitelt két társ között.

Ha ugyanolyan szintű absztrakciót akarunk biztosítani, (sendto/recvfrom tetszőleges partnerekkel előzetes csatlakozás nélkül), akkor az API-n belül „lusta” (késleltetett) kapcsolatot kell létrehozniuk.

Ez történik a „szerver” és a „kliens” közötti normál kommunikáció során UDP használatakor, és mit kell tennie a könyvtárunknak:

  • Szerverhívások bind()hogy közölje az operációs rendszerrel, hogy csomagokat szeretne fogadni a megadott porton.

Ehelyett egy nyitott portot teszünk közzé a Firebase felé a szerverkulcs alatt, és figyeljük az eseményeket annak részfájában.

  • Szerverhívások recvfrom(), fogadja az ezen a porton lévő bármely gazdagéptől érkező csomagokat.

Esetünkben ellenőriznünk kell az erre a portra küldött csomagok bejövő sorát.

Minden portnak saját sora van, és a forrás- és célportokat hozzáadjuk a WebRTC datagramok elejéhez, hogy tudjuk, melyik sorba kell továbbítani, ha új csomag érkezik.

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

  • A kliens valamilyen külső úton kapja meg a szerver IP-jét és portját, és hív sendto(). Ez belső hívást is indít. bind(), ezért későbbi recvfrom() megkapja a választ a bind kifejezett végrehajtása nélkül.

Esetünkben a kliens kívülről kapja meg a string kulcsot és használja a függvényt resolve() IP-cím megszerzéséhez.

Ezen a ponton WebRTC kézfogást kezdeményezünk, ha a két társ még nem kapcsolódik egymáshoz. Ugyanazon partner különböző portjaihoz való kapcsolatok ugyanazt a WebRTC DataChannel-t használják.

Közvetetten is teljesítünk bind()hogy a szerver a következő alkalommal újra csatlakozhasson sendto() hátha valamilyen okból bezárult.

A kiszolgáló értesítést kap az ügyfél kapcsolatáról, amikor az ügyfél a Firebase szerver portinformációi alá írja az SDP-ajánlatát, és a szerver ott válaszol a válaszával.

Az alábbi diagram egy socket séma üzenetfolyamára és az első üzenet továbbítására mutat példát a klienstől a szerver felé:

Többszereplős játék portolása C++-ról az internetre a Cheerp, WebRTC és Firebase segítségével
A kliens és a szerver közötti kapcsolódási fázis teljes diagramja

Következtetés

Ha idáig olvasott, valószínűleg érdekli az elmélet működése. A játék tovább játszható 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 ingyenesen elérhető a címen GitHub. Csatlakozzon a beszélgetéshez csatornánkon a címen Gitter!

Forrás: will.com

Hozzászólás