Cheerp, WebRTC ve Firebase ile çok oyunculu bir oyunu C++'tan web'e taşıma

Giriş

Şirketimiz Eğilme Teknolojileri geleneksel masaüstü uygulamalarının web'e taşınmasına yönelik çözümler sunar. C++ derleyicimiz neşelendirmek WebAssembly ve JavaScript'in bir kombinasyonunu oluşturur; bu da her ikisini de sağlar basit tarayıcı etkileşimive yüksek performans.

Uygulamaya örnek olarak çok oyunculu bir oyunu internete taşımaya karar verdik ve Teeworlds. Teeworlds, küçük ama aktif bir oyuncu topluluğuna (ben de dahil!) sahip, çok oyunculu bir XNUMXD retro oyunudur. Hem indirilen kaynaklar hem de CPU ve GPU gereksinimleri açısından küçüktür - ideal bir adaydır.

Cheerp, WebRTC ve Firebase ile çok oyunculu bir oyunu C++'tan web'e taşıma
Teeworlds tarayıcısında çalıştırma

Bu projeyi denemek için kullanmaya karar verdik. Ağ kodunu web'e taşımak için genel çözümler. Bu genellikle aşağıdaki şekillerde yapılır:

  • XMLHttpRequest/getirağ kısmı yalnızca HTTP isteklerinden oluşuyorsa veya
  • WebSockets.

Her iki çözüm de sunucu tarafında bir sunucu bileşeninin barındırılmasını gerektirir ve her ikisi de aktarım protokolü olarak kullanılmasına izin vermez. UDP. Bu, video konferans yazılımı ve oyunlar gibi gerçek zamanlı uygulamalar için önemlidir çünkü protokol paketlerinin teslimini ve sırasını garanti eder. TCP düşük gecikmeye engel teşkil edebilir.

Üçüncü bir yol var - ağı tarayıcıdan kullanın: WebRTC.

RTCVeri Kanalı Hem güvenilir hem de güvenilmez iletimi destekler (ikinci durumda, mümkün olduğunda UDP'yi aktarım protokolü olarak kullanmaya çalışır) ve hem uzak bir sunucuyla hem de tarayıcılar arasında kullanılabilir. Bu, sunucu bileşeni de dahil olmak üzere tüm uygulamayı tarayıcıya aktarabileceğimiz anlamına gelir!

Ancak bu, ek bir zorluğu da beraberinde getirir: İki WebRTC eşinin iletişim kurabilmesi için, bağlantı kurmak için nispeten karmaşık bir el sıkışma gerçekleştirmeleri gerekir; bu, birkaç üçüncü taraf varlığın (bir sinyalleşme sunucusu ve bir veya daha fazla sunucu) kullanılmasını gerektirir. Sersemletici/DÖNÜŞ).

İdeal olarak, WebRTC'yi dahili olarak kullanan, ancak bağlantı kurması gerekmeyen UDP Soketleri arayüzüne mümkün olduğunca yakın olan bir ağ API'si oluşturmak isteriz.

Bu, uygulama kodundaki karmaşık ayrıntıları ortaya çıkarmak zorunda kalmadan (projemizde bunu mümkün olduğunca az değiştirmek istedik) WebRTC'den yararlanmamıza olanak tanıyacak.

Minimum WebRTC

WebRTC, ses, video ve isteğe bağlı verilerin eşler arası aktarımını sağlayan, tarayıcılarda bulunan bir dizi API'dir.

Eşler arasındaki bağlantı (bir tarafta veya her iki tarafta NAT olsa bile) STUN ve/veya TURN sunucuları kullanılarak ICE adı verilen bir mekanizma aracılığıyla kurulur. Eşler, SDP protokolünün teklif ve yanıtı yoluyla ICE bilgilerini ve kanal parametrelerini paylaşırlar.

Vay! Aynı anda kaç kısaltma var? Bu terimlerin ne anlama geldiğini kısaca açıklayalım:

  • NAT için Oturum Geçişi Yardımcı Programları (Sersemletici) — NAT'ı atlamak ve ana bilgisayarla doğrudan veri alışverişi yapmak için bir çift (IP, bağlantı noktası) elde etmek için bir protokol. Görevini tamamlamayı başarırsa, akranlar birbirleriyle bağımsız olarak veri alışverişinde bulunabilir.
  • NAT Çevresinde Röleleri Kullanarak Geçiş (DÖNÜŞ) NAT geçişi için de kullanılır, ancak bunu her iki eş tarafından görülebilen bir proxy aracılığıyla verileri ileterek gerçekleştirir. Gecikmeye neden olur ve uygulanması STUN'a göre daha pahalıdır (çünkü tüm iletişim oturumu boyunca uygulanır), ancak bazen tek seçenektir.
  • Etkileşimli Bağlantı Kurulumu (ICE) eşlerin doğrudan bağlanmasından elde edilen bilgilerin yanı sıra herhangi bir sayıda STUN ve TURN sunucusundan alınan bilgilere dayanarak iki eşin bağlanması için mümkün olan en iyi yöntemi seçmek için kullanılır.
  • Oturum Açıklama Protokolü (SDP) örneğin ICE adayları, multimedya codec bileşenleri (ses/video kanalı durumunda), vb. gibi bağlantı kanalı parametrelerini açıklayan bir formattır. Eşlerden biri bir SDP Teklifi gönderir ve ikincisi bir SDP Cevabıyla yanıt verir. . . Bundan sonra bir kanal oluşturulur.

Böyle bir bağlantının oluşturulabilmesi için eşlerin STUN ve TURN sunucularından aldıkları bilgileri toplamaları ve birbirleriyle paylaşmaları gerekir.

Sorun şu ki, henüz doğrudan iletişim kurabilme yeteneğine sahip değiller, bu nedenle bu verileri değiş tokuş etmek için bant dışı bir mekanizmanın mevcut olması gerekiyor: bir sinyal sunucusu.

Bir sinyal sunucusu çok basit olabilir çünkü tek görevi el sıkışma aşamasında eşler arasında veri iletmektir (aşağıdaki şemada gösterildiği gibi).

Cheerp, WebRTC ve Firebase ile çok oyunculu bir oyunu C++'tan web'e taşıma
Basitleştirilmiş WebRTC el sıkışma sırası diyagramı

Teeworlds Ağ Modeline Genel Bakış

Teeworlds ağ mimarisi çok basittir:

  • İstemci ve sunucu bileşenleri iki farklı programdır.
  • Müşteriler oyuna, her biri aynı anda yalnızca bir oyunu barındıran çeşitli sunuculardan birine bağlanarak girerler.
  • Oyundaki tüm veri aktarımı sunucu üzerinden gerçekleştirilir.
  • Oyun istemcisinde görüntülenen tüm genel sunucuların bir listesini toplamak için özel bir ana sunucu kullanılır.

Veri alışverişi için WebRTC kullanımı sayesinde oyunun sunucu bileşenini istemcinin bulunduğu tarayıcıya aktarabiliyoruz. Bu bize büyük bir fırsat sunuyor...

Sunuculardan kurtulun

Sunucu mantığının olmayışının güzel bir avantajı var: Uygulamanın tamamını statik içerik olarak Github Sayfalarında veya Cloudflare arkasındaki kendi donanımımızda dağıtabiliyoruz, böylece ücretsiz olarak hızlı indirmeler ve yüksek çalışma süresi sağlıyoruz. Aslında bunları unutabiliriz ve eğer şanslıysak ve oyun popüler hale gelirse altyapının modernleştirilmesine gerek kalmayacak.

Ancak sistemin çalışması için yine de harici bir mimari kullanmamız gerekiyor:

  • Bir veya daha fazla STUN sunucusu: Aralarından seçim yapabileceğiniz birçok ücretsiz seçeneğimiz var.
  • En az bir TURN sunucusu: Burada ücretsiz seçenek yoktur, bu nedenle ya kendi sunucumuzu kurabilir ya da hizmet için ödeme yapabiliriz. Neyse ki, çoğu zaman bağlantı STUN sunucuları aracılığıyla kurulabilir (ve gerçek p2p sağlar), ancak bir geri dönüş seçeneği olarak TURN'a ihtiyaç vardır.
  • Sinyal Sunucusu: Diğer iki hususun aksine sinyalizasyon standartlaştırılmamıştır. Sinyal sunucusunun gerçekte neyden sorumlu olacağı uygulamaya bağlıdır. Bizim durumumuzda bağlantı kurmadan önce az miktarda veri alışverişi yapmak gerekiyor.
  • Teeworlds Master Server: Diğer sunucular tarafından varlıklarının duyurulması, istemciler tarafından da genel sunucuların bulunması için kullanılır. Her ne kadar gerekli olmasa da (istemciler her zaman manuel olarak bildikleri bir sunucuya bağlanabilirler), oyuncuların rastgele kişilerle oyunlara katılabilmelerini sağlamak güzel olurdu.

Google'ın ücretsiz STUN sunucularını kullanmaya karar verdik ve bir TURN sunucusunu kendimiz kurduk.

Kullandığımız son iki nokta için Firebase:

  • Teeworlds ana sunucusu çok basit bir şekilde uygulanır: her aktif sunucunun bilgilerini (isim, IP, harita, mod, ...) içeren nesnelerin bir listesi olarak. Sunucular kendi nesnelerini yayınlar ve güncellerler ve istemciler de listenin tamamını alıp oynatıcıya görüntüler. Ayrıca listeyi ana sayfada HTML olarak görüntülüyoruz, böylece oyuncular sunucuya tıklayıp doğrudan oyuna yönlendirilebilirler.
  • Sinyalleşme, bir sonraki bölümde açıklanan soket uygulamamızla yakından ilgilidir.

Cheerp, WebRTC ve Firebase ile çok oyunculu bir oyunu C++'tan web'e taşıma
Oyun içindeki ve ana sayfadaki sunucuların listesi

Soketlerin uygulanması

Gerekli değişiklik sayısını en aza indirmek için Posix UDP Soketlerine mümkün olduğunca yakın bir API oluşturmak istiyoruz.

Ayrıca ağ üzerinden en basit veri alışverişi için gereken minimum düzeyi de uygulamak istiyoruz.

Örneğin, gerçek yönlendirmeye ihtiyacımız yok: tüm eşler, belirli bir Firebase veritabanı örneğiyle ilişkili aynı "sanal LAN" üzerindedir.

Bu nedenle, benzersiz IP adreslerine ihtiyacımız yok: benzersiz Firebase anahtar değerleri (alan adlarına benzer), eşleri benzersiz bir şekilde tanımlamak için yeterlidir ve her eş, çevrilmesi gereken her anahtara yerel olarak "sahte" IP adresleri atar. Bu, önemsiz bir görev olan global IP adresi atama ihtiyacını tamamen ortadan kaldırır.

Uygulamamız gereken minimum API:

// 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 basit ve Posix Sockets API'sine benzer ancak birkaç önemli farklılığı vardır: Geri aramaların günlüğe kaydedilmesi, yerel IP'lerin atanması ve tembel bağlantılar.

Geri Aramaları Kaydetme

Orijinal program engellemeyen G/Ç kullanıyor olsa bile, kodun bir web tarayıcısında çalışması için yeniden düzenlenmesi gerekir.

Bunun nedeni tarayıcıdaki olay döngüsünün programdan gizlenmiş olmasıdır (JavaScript veya WebAssembly olsun).

Yerel ortamda şöyle kod yazabiliriz

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

Eğer olay döngüsü bizim için gizliyse, onu şuna benzer bir şeye dönüştürmemiz gerekir:

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

Yerel IP ataması

"Ağımızdaki" düğüm kimlikleri IP adresleri değil, Firebase anahtarlarıdır (bunlar şuna benzeyen dizelerdir: -LmEC50PYZLCiCP-vqde ).

Bu kullanışlıdır çünkü IP'leri atamak ve benzersizliklerini kontrol etmek (aynı zamanda istemcinin bağlantısı kesildikten sonra bunları elden çıkarmak) için bir mekanizmaya ihtiyacımız yoktur, ancak eşleri sayısal bir değere göre tanımlamak genellikle gereklidir.

Fonksiyonlar tam olarak bunun için kullanılıyor. resolve и reverseResolve: Uygulama bir şekilde anahtarın dize değerini alır (kullanıcı girişi yoluyla veya ana sunucu aracılığıyla) ve bunu dahili kullanım için bir IP adresine dönüştürebilir. Basitlik açısından API'nin geri kalanı da dize yerine bu değeri alır.

Bu, DNS aramasına benzer ancak istemcide yerel olarak gerçekleştirilir.

Yani, IP adresleri farklı istemciler arasında paylaşılamaz ve eğer bir tür genel tanımlayıcıya ihtiyaç duyulursa, bunun farklı bir şekilde oluşturulması gerekecektir.

Tembel bağlantı

UDP'nin bir bağlantıya ihtiyacı yoktur ancak gördüğümüz gibi WebRTC, iki eş arasında veri aktarımına başlamadan önce uzun bir bağlantı süreci gerektirir.

Aynı düzeyde soyutlama sağlamak istiyorsak, (sendto/recvfrom önceden bağlantısı olmayan rastgele eşlerle), bu durumda API içinde "tembel" (gecikmeli) bir bağlantı gerçekleştirmeleri gerekir.

UDP kullanırken "sunucu" ile "istemci" arasındaki normal iletişim sırasında olan ve kütüphanemizin yapması gerekenler şunlardır:

  • Sunucu çağrıları bind()işletim sistemine belirtilen bağlantı noktasındaki paketleri almak istediğini söylemek için.

Bunun yerine, sunucu anahtarı altında Firebase'e açık bir bağlantı noktası yayınlayacağız ve alt ağacındaki olayları dinleyeceğiz.

  • Sunucu çağrıları recvfrom(), bu bağlantı noktasındaki herhangi bir ana bilgisayardan gelen paketleri kabul eder.

Bizim durumumuzda bu porta gönderilen paketlerin gelen kuyruğunu kontrol etmemiz gerekiyor.

Her portun kendi kuyruğu vardır ve kaynak ve hedef portları WebRTC datagramlarının başlangıcına ekleriz, böylece yeni bir paket geldiğinde hangi kuyruğa ileteceğimizi biliriz.

Çağrı engellenmez, yani eğer paket yoksa -1 değerini döndürürüz ve errno=EWOULDBLOCK.

  • İstemci, sunucunun IP'sini ve bağlantı noktasını bazı harici yollarla alır ve çağrı yapar. sendto(). Bu aynı zamanda dahili bir arama da yapar. bind(), bu nedenle sonraki recvfrom() açıkça bağlamayı yürütmeden yanıtı alacaktır.

Bizim durumumuzda, müşteri harici olarak dize anahtarını alır ve işlevi kullanır. resolve() Bir IP adresi almak için.

Bu noktada eğer iki eş henüz birbirine bağlı değilse WebRTC el sıkışmasını başlatıyoruz. Aynı eşin farklı bağlantı noktalarına yapılan bağlantılar aynı WebRTC DataChannel'ı kullanır.

Ayrıca dolaylı olarak da gerçekleştiriyoruz. bind()sunucunun bir sonraki adımda yeniden bağlanabilmesi için sendto() herhangi bir nedenle kapanması durumunda.

İstemci, SDP teklifini Firebase'deki sunucu bağlantı noktası bilgileri altına yazdığında, istemcinin bağlantısı sunucuya bildirilir ve sunucu, oradaki yanıtıyla yanıt verir.

Aşağıdaki diyagram, bir soket şeması için mesaj akışının bir örneğini ve ilk mesajın istemciden sunucuya iletilmesini göstermektedir:

Cheerp, WebRTC ve Firebase ile çok oyunculu bir oyunu C++'tan web'e taşıma
İstemci ve sunucu arasındaki bağlantı aşamasının tam diyagramı

Sonuç

Buraya kadar okuduysanız muhtemelen teoriyi çalışırken görmek ilginizi çekecektir. Oyun şu adreste oynanabilir: teeworlds.leaningtech.com, dene!


Meslektaşlar arasında dostluk maçı

Ağ kütüphanesi kodu şu adreste ücretsiz olarak mevcuttur: Github. Kanalımızdaki sohbete katılın ızgara!

Kaynak: habr.com

Yorum ekle