Przenoszenie gry wieloosobowej z C++ do Internetu za pomocą Cheerp, WebRTC i Firebase

Wprowadzenie

Nasza kompania Technologie pochylone dostarcza rozwiązania umożliwiające przenoszenie tradycyjnych aplikacji desktopowych do Internetu. Nasz kompilator C++ wesoły generuje kombinację WebAssembly i JavaScript, która zapewnia oba prosta interakcja z przeglądarkąi wysoką wydajność.

Jako przykład zastosowania zdecydowaliśmy się przenieść grę wieloosobową do sieci i wybraliśmy Teeworlds. Teeworlds to wieloosobowa gra retro XNUMXD z małą, ale aktywną społecznością graczy (łącznie ze mną!). Jest mały zarówno pod względem pobieranych zasobów, jak i wymagań procesora i karty graficznej - idealny kandydat.

Przenoszenie gry wieloosobowej z C++ do Internetu za pomocą Cheerp, WebRTC i Firebase
Działa w przeglądarce Teeworlds

Postanowiliśmy wykorzystać ten projekt do eksperymentów ogólne rozwiązania dotyczące przenoszenia kodu sieciowego do Internetu. Zwykle odbywa się to w następujący sposób:

  • XMLHttpRequest/pobierz, jeśli część sieciowa składa się wyłącznie z żądań HTTP, lub
  • WebSockets.

Obydwa rozwiązania wymagają hostowania komponentu serwerowego po stronie serwera i żadne z nich nie pozwala na użycie ich jako protokołu transportowego UDP. Jest to ważne w przypadku aplikacji czasu rzeczywistego, takich jak oprogramowanie i gry do wideokonferencji, ponieważ gwarantuje dostarczenie i uporządkowanie pakietów protokołów TCP może stać się przeszkodą w osiągnięciu niskich opóźnień.

Istnieje trzeci sposób - skorzystaj z sieci z przeglądarki: WebRTC.

Kanał RTCData Obsługuje zarówno niezawodną, ​​jak i zawodną transmisję (w tym drugim przypadku, gdy tylko jest to możliwe, stara się używać UDP jako protokołu transportowego) i może być używany zarówno ze zdalnym serwerem, jak i pomiędzy przeglądarkami. Oznacza to, że możemy przenieść na przeglądarkę całą aplikację, łącznie z komponentem serwerowym!

Wiąże się to jednak z dodatkową trudnością: zanim dwaj równorzędni użytkownicy WebRTC będą mogli się komunikować, muszą wykonać stosunkowo złożone uzgadnianie, aby się połączyć, co wymaga kilku podmiotów zewnętrznych (serwera sygnalizacyjnego i jednego lub więcej serwerów Oszołomić/SKRĘCAĆ).

Idealnie byłoby, gdybyśmy stworzyli sieciowy interfejs API, który wewnętrznie korzysta z WebRTC, ale jest jak najbardziej zbliżony do interfejsu gniazd UDP, który nie wymaga nawiązywania połączenia.

Pozwoli nam to na wykorzystanie WebRTC bez konieczności eksponowania skomplikowanych szczegółów w kodzie aplikacji (którego w naszym projekcie chcieliśmy zmieniać jak najmniej).

Minimalny WebRTC

WebRTC to zestaw interfejsów API dostępnych w przeglądarkach, który zapewnia transmisję audio, wideo i dowolnych danych w trybie peer-to-peer.

Połączenie między urządzeniami równorzędnymi jest ustanawiane (nawet jeśli po jednej lub obu stronach znajduje się NAT) przy użyciu serwerów STUN i/lub TURN poprzez mechanizm zwany ICE. Uczestnicy wymieniają informacje ICE i parametry kanału za pośrednictwem oferty i odpowiedzi protokołu SDP.

Wow! Ile skrótów jednocześnie? Wyjaśnijmy pokrótce, co oznaczają te terminy:

  • Narzędzia przechodzenia sesji dla NAT (Oszołomić) — protokół omijania NAT i uzyskiwania pary (IP, portu) do bezpośredniej wymiany danych z hostem. Jeśli uda mu się wykonać swoje zadanie, wówczas rówieśnicy mogą samodzielnie wymieniać między sobą dane.
  • Przechodzenie za pomocą przekaźników wokół NAT (SKRĘCAĆ) jest również używany do przechodzenia przez NAT, ale realizuje to poprzez przesyłanie danych przez serwer proxy, który jest widoczny dla obu równorzędnych urządzeń. Dodaje opóźnienia i jest droższy w implementacji niż STUN (ponieważ jest stosowany przez całą sesję komunikacyjną), ale czasami jest jedyną opcją.
  • Interaktywne nawiązywanie łączności (ICE) służy do wyboru najlepszej możliwej metody połączenia dwóch równorzędnych urządzeń na podstawie informacji uzyskanych bezpośrednio od łączących się równorzędników, a także informacji otrzymanych przez dowolną liczbę serwerów STUN i TURN.
  • Protokół opisu sesji (SDP) to format opisu parametrów kanału połączenia, np. kandydatów ICE, kodeków multimedialnych (w przypadku kanału audio/wideo) itp. Jeden z peerów wysyła ofertę SDP, a drugi odpowiada odpowiedzią SDP . . Następnie tworzony jest kanał.

Aby utworzyć takie połączenie, peery muszą zebrać informacje, które otrzymują z serwerów STUN i TURN i wymieniać je między sobą.

Problem polega na tym, że nie mają jeszcze możliwości bezpośredniej komunikacji, dlatego do wymiany tych danych musi istnieć mechanizm pozapasmowy: serwer sygnalizacyjny.

Serwer sygnalizacyjny może być bardzo prosty, ponieważ jego jedynym zadaniem jest przekazywanie danych pomiędzy urządzeniami równorzędnymi w fazie uzgadniania (jak pokazano na poniższym schemacie).

Przenoszenie gry wieloosobowej z C++ do Internetu za pomocą Cheerp, WebRTC i Firebase
Uproszczony diagram sekwencji uzgadniania WebRTC

Przegląd modelu sieci Teeworlds

Architektura sieci Teeworlds jest bardzo prosta:

  • Składniki klienta i serwera to dwa różne programy.
  • Klienci wchodzą do gry, łącząc się z jednym z kilku serwerów, z których każdy obsługuje tylko jedną grę na raz.
  • Cały transfer danych w grze odbywa się poprzez serwer.
  • Specjalny serwer główny służy do gromadzenia listy wszystkich serwerów publicznych wyświetlanych w kliencie gry.

Dzięki wykorzystaniu WebRTC do wymiany danych możemy przenieść komponent serwerowy gry do przeglądarki, w której znajduje się klient. Daje nam to ogromną szansę...

Pozbądź się serwerów

Brak logiki serwera ma dobrą zaletę: możemy wdrożyć całą aplikację jako zawartość statyczną na stronach Github lub na naszym własnym sprzęcie za Cloudflare, zapewniając w ten sposób szybkie pobieranie i wysoki czas pracy za darmo. Tak naprawdę możemy o nich zapomnieć, a jeśli będziemy mieli szczęście i gra stanie się popularna, to infrastruktury nie będzie trzeba modernizować.

Aby jednak system działał, musimy jeszcze skorzystać z architektury zewnętrznej:

  • Jeden lub więcej serwerów STUN: Mamy kilka bezpłatnych opcji do wyboru.
  • Przynajmniej jeden serwer TURN: tutaj nie ma darmowych opcji, więc możemy albo założyć własny, albo zapłacić za usługę. Na szczęście w większości przypadków połączenie można nawiązać za pośrednictwem serwerów STUN (i zapewnić prawdziwe p2p), ale TURN jest potrzebny jako opcja awaryjna.
  • Serwer sygnalizacyjny: W przeciwieństwie do pozostałych dwóch aspektów, sygnalizacja nie jest ustandaryzowana. To, za co faktycznie będzie odpowiedzialny serwer sygnalizacyjny, zależy w pewnym stopniu od aplikacji. W naszym przypadku przed nawiązaniem połączenia konieczna jest wymiana niewielkiej ilości danych.
  • Serwer główny Teeworlds: Jest używany przez inne serwery do reklamowania swojego istnienia, a klienci do wyszukiwania serwerów publicznych. Chociaż nie jest to wymagane (klienci zawsze mogą ręcznie połączyć się ze znanym im serwerem), byłoby miło, gdyby gracze mogli brać udział w grach z przypadkowymi osobami.

Zdecydowaliśmy się skorzystać z bezpłatnych serwerów STUN firmy Google i sami wdrożyliśmy jeden serwer TURN.

W przypadku dwóch ostatnich punktów wykorzystaliśmy Ognisko:

  • Serwer główny Teeworlds jest zaimplementowany w bardzo prosty sposób: jako lista obiektów zawierających informacje (nazwa, adres IP, mapa, tryb, ...) każdego aktywnego serwera. Serwery publikują i aktualizują własne obiekty, a klienci pobierają całą listę i wyświetlają ją graczowi. Wyświetlamy także listę na stronie głównej w formacie HTML, dzięki czemu gracze mogą po prostu kliknąć serwer i zostać przeniesieni bezpośrednio do gry.
  • Sygnalizacja jest ściśle powiązana z naszą implementacją gniazd, opisaną w następnej sekcji.

Przenoszenie gry wieloosobowej z C++ do Internetu za pomocą Cheerp, WebRTC i Firebase
Lista serwerów wewnątrz gry i na stronie głównej

Implementacja gniazd

Chcemy stworzyć API jak najbardziej zbliżone do Posix UDP Sockets, aby zminimalizować ilość potrzebnych zmian.

Chcemy także wdrożyć niezbędne minimum wymagane do najprostszej wymiany danych w sieci.

Na przykład nie potrzebujemy prawdziwego routingu: wszyscy równorzędni znajdują się w tej samej „wirtualnej sieci LAN” powiązanej z konkretną instancją bazy danych Firebase.

Dlatego nie potrzebujemy unikalnych adresów IP: unikalne wartości kluczy Firebase (podobnie jak nazwy domen) wystarczą, aby jednoznacznie zidentyfikować peery, a każdy peer lokalnie przypisuje „fałszywe” adresy IP do każdego klucza, który ma zostać przetłumaczony. Eliminuje to całkowicie potrzebę przydzielania globalnego adresu IP, co jest zadaniem nietrywialnym.

Oto minimalny interfejs API, który musimy wdrożyć:

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

Interfejs API jest prosty i podobny do interfejsu API Posix Sockets, ale ma kilka ważnych różnic: rejestrowanie wywołań zwrotnych, przypisywanie lokalnych adresów IP i leniwe połączenia.

Rejestrowanie wywołań zwrotnych

Nawet jeśli oryginalny program wykorzystuje nieblokujące wejścia/wyjścia, kod musi zostać poddany refaktoryzacji, aby mógł działać w przeglądarce internetowej.

Powodem tego jest to, że pętla zdarzeń w przeglądarce jest ukryta przed programem (czy to JavaScript, czy WebAssembly).

W środowisku natywnym możemy napisać taki 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;
    ...
  }
  ...
}

Jeśli pętla zdarzeń jest dla nas ukryta, musimy przekształcić ją w coś takiego:

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

Lokalne przypisanie adresu IP

Identyfikatory węzłów w naszej „sieci” nie są adresami IP, ale kluczami Firebase (są to ciągi znaków wyglądające tak: -LmEC50PYZLCiCP-vqde ).

Jest to wygodne, ponieważ nie potrzebujemy mechanizmu przydzielania adresów IP i sprawdzania ich unikalności (a także usuwania ich po rozłączeniu klienta), ale często konieczna jest identyfikacja peerów po wartości liczbowej.

Właśnie do tego służą te funkcje. resolve и reverseResolve: Aplikacja w jakiś sposób otrzymuje wartość ciągu klucza (poprzez dane wprowadzone przez użytkownika lub za pośrednictwem serwera głównego) i może przekonwertować ją na adres IP do użytku wewnętrznego. Dla uproszczenia reszta interfejsu API również otrzymuje tę wartość zamiast ciągu znaków.

Jest to podobne do wyszukiwania DNS, ale wykonywane lokalnie na kliencie.

Oznacza to, że adresy IP nie mogą być współdzielone pomiędzy różnymi klientami, a jeśli potrzebny jest jakiś globalny identyfikator, trzeba będzie go wygenerować w inny sposób.

Leniwe połączenie

UDP nie potrzebuje połączenia, ale jak widzieliśmy, WebRTC wymaga długiego procesu łączenia, zanim będzie mógł rozpocząć przesyłanie danych między dwoma urządzeniami równorzędnymi.

Jeśli chcemy zapewnić ten sam poziom abstrakcji, (sendto/recvfrom z dowolnymi peerami bez wcześniejszego połączenia), wówczas muszą wykonać „leniwe” (opóźnione) połączenie wewnątrz API.

Oto co dzieje się podczas normalnej komunikacji pomiędzy „serwerem” a „klientem” podczas korzystania z protokołu UDP i co powinna zrobić nasza biblioteka:

  • Połączenia z serwerem bind()aby poinformować system operacyjny, że chce odbierać pakiety na określonym porcie.

Zamiast tego opublikujemy otwarty port do Firebase pod kluczem serwera i będziemy nasłuchiwać zdarzeń w jego poddrzewie.

  • Połączenia z serwerem recvfrom(), akceptując pakiety przychodzące od dowolnego hosta na tym porcie.

W naszym przypadku musimy sprawdzić przychodzącą kolejkę pakietów wysyłanych do tego portu.

Każdy port ma własną kolejkę. Porty źródłowy i docelowy dodajemy na początku datagramów WebRTC, abyśmy wiedzieli, do której kolejki należy przekazać dalej, gdy nadejdzie nowy pakiet.

Wywołanie nie jest blokowane, więc jeśli nie ma pakietów, po prostu zwracamy -1 i ustawiamy errno=EWOULDBLOCK.

  • Klient otrzymuje adres IP i port serwera za pomocą środków zewnętrznych i wykonuje połączenia sendto(). Spowoduje to również wykonanie połączenia wewnętrznego. bind(), zatem kolejne recvfrom() otrzyma odpowiedź bez jawnego wykonywania powiązania.

W naszym przypadku klient z zewnątrz otrzymuje klucz typu string i korzysta z funkcji resolve() w celu uzyskania adresu IP.

W tym momencie inicjujemy uzgadnianie WebRTC, jeśli obaj partnerzy nie są jeszcze ze sobą połączeni. Połączenia z różnymi portami tego samego partnera wykorzystują ten sam kanał danych WebRTC.

Wykonujemy również pośrednio bind()aby serwer mógł ponownie połączyć się w następnym sendto() na wypadek, gdyby z jakiegoś powodu zostało zamknięte.

Serwer jest powiadamiany o połączeniu klienta, gdy klient zapisuje swoją ofertę SDP pod informacjami o porcie serwera w Firebase, a serwer tam odpowiada swoją odpowiedzią.

Poniższy diagram przedstawia przykładowy przepływ komunikatów dla schematu gniazd oraz transmisję pierwszego komunikatu od klienta do serwera:

Przenoszenie gry wieloosobowej z C++ do Internetu za pomocą Cheerp, WebRTC i Firebase
Pełny diagram fazy połączenia pomiędzy klientem a serwerem

wniosek

Jeśli doczytałeś aż dotąd, prawdopodobnie interesuje Cię teoria w praktyce. W grę można grać dalej teeworlds.leaningtech.com, Spróbuj!


Towarzyski mecz pomiędzy kolegami

Kod biblioteki sieciowej jest swobodnie dostępny pod adresem Github. Dołącz do rozmowy na naszym kanale pod adresem ruszt!

Źródło: www.habr.com

Dodaj komentarz