Memindahkan game multipemain dari C++ ke web dengan Cheerp, WebRTC, dan Firebase

pengenalan

Kompromi ini Teknologi Miring memberikan solusi untuk porting aplikasi desktop tradisional ke web. Kompiler C++ kami sorak-sorai menghasilkan kombinasi WebAssembly dan JavaScript, yang menyediakan keduanya interaksi browser sederhana, dan kinerja tinggi.

Sebagai contoh penerapannya, kami memutuskan untuk mem-porting game multipemain ke web dan memilih Teeworlds. Teeworlds adalah game retro XNUMXD multipemain dengan komunitas pemain kecil namun aktif (termasuk saya!). Ini kecil baik dalam hal sumber daya yang diunduh dan persyaratan CPU dan GPU - kandidat yang ideal.

Memindahkan game multipemain dari C++ ke web dengan Cheerp, WebRTC, dan Firebase
Berjalan di browser Teeworlds

Kami memutuskan untuk menggunakan proyek ini untuk bereksperimen solusi umum untuk mem-porting kode jaringan ke web. Hal ini biasanya dilakukan dengan cara berikut:

  • XMLHttpRequest/fetch, jika bagian jaringan hanya terdiri dari permintaan HTTP, atau
  • WebSockets.

Kedua solusi tersebut memerlukan hosting komponen server di sisi server, dan tidak ada yang memungkinkan untuk digunakan sebagai protokol transport UDP. Hal ini penting untuk aplikasi real-time seperti perangkat lunak konferensi video dan permainan, karena menjamin pengiriman dan urutan paket protokol. TCP mungkin menjadi penghalang untuk latensi rendah.

Ada cara ketiga - gunakan jaringan dari browser: WebRTC.

Saluran Data RTC Ini mendukung transmisi yang andal dan tidak dapat diandalkan (dalam kasus terakhir, ia mencoba menggunakan UDP sebagai protokol transport bila memungkinkan), dan dapat digunakan baik dengan server jarak jauh maupun antar browser. Artinya kita dapat mem-porting seluruh aplikasi ke browser, termasuk komponen server!

Namun, hal ini menimbulkan kesulitan tambahan: sebelum dua rekan WebRTC dapat berkomunikasi, mereka perlu melakukan jabat tangan yang relatif rumit untuk terhubung, yang memerlukan beberapa entitas pihak ketiga (server sinyal dan satu atau lebih server). setrum/MENGHIDUPKAN).

Idealnya, kami ingin membuat API jaringan yang menggunakan WebRTC secara internal, namun sedekat mungkin dengan antarmuka Soket UDP yang tidak perlu membuat koneksi.

Ini akan memungkinkan kami memanfaatkan WebRTC tanpa harus memaparkan detail rumit ke kode aplikasi (yang ingin kami ubah sesedikit mungkin dalam proyek kami).

Minimal WebRTC

WebRTC adalah sekumpulan API yang tersedia di browser yang menyediakan transmisi audio, video, dan data arbitrer secara peer-to-peer.

Koneksi antar peer dibuat (walaupun ada NAT di satu atau kedua sisi) menggunakan server STUN dan/atau TURN melalui mekanisme yang disebut ICE. Rekan bertukar informasi ICE dan parameter saluran melalui penawaran dan jawaban protokol SDP.

Wow! Berapa banyak singkatan sekaligus? Mari kita jelaskan secara singkat apa arti istilah-istilah ini:

  • Utilitas Traversal Sesi untuk NAT (setrum) β€” protokol untuk melewati NAT dan mendapatkan pasangan (IP, port) untuk bertukar data secara langsung dengan host. Jika ia berhasil menyelesaikan tugasnya, maka rekan-rekannya dapat saling bertukar data secara mandiri.
  • Traversal Menggunakan Relay di sekitar NAT (MENGHIDUPKAN) juga digunakan untuk traversal NAT, namun mengimplementasikannya dengan meneruskan data melalui proxy yang terlihat oleh kedua rekan. Ini menambah latensi dan lebih mahal untuk diterapkan daripada STUN (karena diterapkan di seluruh sesi komunikasi), namun terkadang ini adalah satu-satunya pilihan.
  • Pembentukan Konektivitas Interaktif (ES) digunakan untuk memilih metode terbaik untuk menghubungkan dua peer berdasarkan informasi yang diperoleh dari menghubungkan peer secara langsung, serta informasi yang diterima oleh sejumlah server STUN dan TURN.
  • Protokol Deskripsi Sesi (SDP) adalah format untuk menjelaskan parameter saluran koneksi, misalnya, kandidat ICE, codec multimedia (dalam kasus saluran audio/video), dll... Salah satu rekan mengirimkan Penawaran SDP, dan rekan kedua merespons dengan Jawaban SDP . . Setelah ini, saluran dibuat.

Untuk membuat koneksi seperti itu, rekan-rekan perlu mengumpulkan informasi yang mereka terima dari server STUN dan TURN dan menukarnya satu sama lain.

Masalahnya adalah mereka belum mempunyai kemampuan untuk berkomunikasi secara langsung, sehingga harus ada mekanisme out-of-band untuk bertukar data ini: server sinyal.

Server pensinyalan bisa sangat sederhana karena tugasnya hanya meneruskan data antar rekan dalam fase jabat tangan (seperti yang ditunjukkan pada diagram di bawah).

Memindahkan game multipemain dari C++ ke web dengan Cheerp, WebRTC, dan Firebase
Diagram urutan jabat tangan WebRTC yang disederhanakan

Ikhtisar Model Jaringan Teeworlds

Arsitektur jaringan Teeworlds sangat sederhana:

  • Komponen klien dan server adalah dua program yang berbeda.
  • Klien memasuki game dengan menghubungkan ke salah satu dari beberapa server, yang masing-masing server hanya menampung satu game dalam satu waktu.
  • Semua transfer data dalam game dilakukan melalui server.
  • Server master khusus digunakan untuk mengumpulkan daftar semua server publik yang ditampilkan di klien game.

Berkat penggunaan WebRTC untuk pertukaran data, kami dapat mentransfer komponen server game ke browser tempat klien berada. Ini memberi kita peluang besar...

Singkirkan server

Kurangnya logika server memiliki keuntungan yang bagus: kita dapat menyebarkan seluruh aplikasi sebagai konten statis di Halaman Github atau pada perangkat keras kita sendiri di belakang Cloudflare, sehingga memastikan pengunduhan cepat dan waktu aktif yang tinggi secara gratis. Faktanya, kita bisa melupakannya, dan jika kita beruntung dan game tersebut menjadi populer, maka infrastrukturnya tidak perlu dimodernisasi.

Namun, agar sistem dapat berfungsi, kita masih harus menggunakan arsitektur eksternal:

  • Satu atau lebih server STUN: Kami memiliki beberapa opsi gratis untuk dipilih.
  • Setidaknya satu server TURN: tidak ada opsi gratis di sini, jadi kami dapat mengaturnya sendiri atau membayar layanannya. Untungnya, sebagian besar koneksi dapat dibuat melalui server STUN (dan menyediakan p2p yang sebenarnya), namun TURN diperlukan sebagai opsi cadangan.
  • Server Persinyalan: Berbeda dengan dua aspek lainnya, persinyalan tidak terstandarisasi. Apa yang sebenarnya menjadi tanggung jawab server sinyal tergantung pada aplikasinya. Dalam kasus kami, sebelum membuat koneksi, perlu untuk bertukar sejumlah kecil data.
  • Server Master Teeworlds: Digunakan oleh server lain untuk mengiklankan keberadaan mereka dan oleh klien untuk menemukan server publik. Meskipun tidak diperlukan (klien selalu dapat terhubung ke server yang mereka ketahui secara manual), akan lebih baik jika pemain dapat berpartisipasi dalam permainan dengan orang secara acak.

Kami memutuskan untuk menggunakan server STUN gratis dari Google, dan menerapkan sendiri satu server TURN.

Untuk dua poin terakhir kami gunakan Firebase:

  • Server master Teeworlds diimplementasikan dengan sangat sederhana: sebagai daftar objek yang berisi informasi (nama, IP, peta, mode, ...) dari setiap server aktif. Server menerbitkan dan memperbarui objek mereka sendiri, dan klien mengambil seluruh daftar dan menampilkannya ke pemain. Kami juga menampilkan daftar di halaman beranda sebagai HTML sehingga pemain cukup mengklik server dan langsung dibawa ke permainan.
  • Pensinyalan terkait erat dengan implementasi soket kami, yang dijelaskan di bagian selanjutnya.

Memindahkan game multipemain dari C++ ke web dengan Cheerp, WebRTC, dan Firebase
Daftar server di dalam game dan di halaman beranda

Implementasi soket

Kami ingin membuat API yang sedekat mungkin dengan Soket UDP Posix untuk meminimalkan jumlah perubahan yang diperlukan.

Kami juga ingin menerapkan persyaratan minimum untuk pertukaran data paling sederhana melalui jaringan.

Misalnya, kita tidak memerlukan perutean sebenarnya: semua peer berada di "LAN virtual" yang sama dan terkait dengan instance database Firebase tertentu.

Oleh karena itu, kami tidak memerlukan alamat IP unik: nilai kunci Firebase yang unik (mirip dengan nama domain) sudah cukup untuk mengidentifikasi rekan secara unik, dan setiap rekan secara lokal memberikan alamat IP "palsu" ke setiap kunci yang perlu diterjemahkan. Hal ini sepenuhnya menghilangkan kebutuhan akan penetapan alamat IP global, yang merupakan tugas yang tidak sepele.

Berikut adalah API minimum yang perlu kita terapkan:

// 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 ini sederhana dan mirip dengan Posix Sockets API, namun memiliki beberapa perbedaan penting: mencatat panggilan balik, menetapkan IP lokal, dan koneksi lambat.

Mendaftarkan Panggilan Balik

Meskipun program aslinya menggunakan I/O non-pemblokiran, kode tersebut harus difaktorkan ulang agar dapat dijalankan di browser web.

Alasannya adalah karena event loop di browser disembunyikan dari program (baik itu JavaScript atau WebAssembly).

Di lingkungan asli kita bisa menulis kode seperti ini

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

Jika perulangan peristiwa disembunyikan bagi kita, maka kita perlu mengubahnya menjadi seperti ini:

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

Penetapan IP lokal

ID node di "jaringan" kami bukanlah alamat IP, tetapi kunci Firebase (itu adalah string yang terlihat seperti ini: -LmEC50PYZLCiCP-vqde ).

Hal ini nyaman karena kita tidak memerlukan mekanisme untuk menetapkan IP dan memeriksa keunikannya (serta membuangnya setelah klien terputus), namun seringkali kita perlu mengidentifikasi rekan dengan nilai numerik.

Untuk itulah fungsi-fungsi tersebut digunakan. resolve ΠΈ reverseResolve: Aplikasi entah bagaimana menerima nilai string kunci (melalui input pengguna atau melalui server master), dan dapat mengubahnya menjadi alamat IP untuk penggunaan internal. API lainnya juga menerima nilai ini, bukan string, untuk kesederhanaan.

Ini mirip dengan pencarian DNS, namun dilakukan secara lokal pada klien.

Artinya, alamat IP tidak dapat dibagikan antara klien yang berbeda, dan jika diperlukan semacam pengenal global, alamat tersebut harus dibuat dengan cara yang berbeda.

Koneksi malas

UDP tidak memerlukan koneksi, tetapi seperti yang telah kita lihat, WebRTC memerlukan proses koneksi yang panjang sebelum dapat mulai mentransfer data antara dua rekan.

Jika kita ingin memberikan tingkat abstraksi yang sama, (sendto/recvfrom dengan rekan-rekan sewenang-wenang tanpa koneksi sebelumnya), maka mereka harus melakukan koneksi β€œmalas” (tertunda) di dalam API.

Inilah yang terjadi selama komunikasi normal antara β€œserver” dan β€œklien” saat menggunakan UDP, dan apa yang harus dilakukan perpustakaan kita:

  • Panggilan server bind()untuk memberi tahu sistem operasi bahwa ia ingin menerima paket pada port yang ditentukan.

Sebagai gantinya, kami akan memublikasikan port terbuka ke Firebase di bawah kunci server dan mendengarkan peristiwa di subpohonnya.

  • Panggilan server recvfrom(), menerima paket yang datang dari host mana pun di port ini.

Dalam kasus kita, kita perlu memeriksa antrian masuk paket yang dikirim ke port ini.

Setiap port memiliki antriannya sendiri, dan kami menambahkan port sumber dan tujuan ke awal datagram WebRTC sehingga kami mengetahui antrian mana yang akan diteruskan ketika paket baru tiba.

Panggilannya non-blocking, jadi jika tidak ada paket kita cukup mengembalikan -1 dan set errno=EWOULDBLOCK.

  • Klien menerima IP dan port server melalui beberapa cara eksternal, dan melakukan panggilan sendto(). Ini juga menimbulkan panggilan internal. bind(), oleh karena itu selanjutnya recvfrom() akan menerima respons tanpa mengeksekusi pengikatan secara eksplisit.

Dalam kasus kami, klien secara eksternal menerima kunci string dan menggunakan fungsinya resolve() untuk mendapatkan alamat IP.

Pada titik ini, kami memulai jabat tangan WebRTC jika kedua rekan tersebut belum terhubung satu sama lain. Koneksi ke port berbeda dari rekan yang sama menggunakan WebRTC DataChannel yang sama.

Kami juga melakukan tidak langsung bind()sehingga server dapat terhubung kembali di lain waktu sendto() kalau-kalau ditutup karena alasan tertentu.

Server diberitahu tentang koneksi klien ketika klien menulis penawaran SDP-nya di bawah informasi port server di Firebase, dan server merespons dengan responsnya di sana.

Diagram di bawah menunjukkan contoh aliran pesan untuk skema soket dan transmisi pesan pertama dari klien ke server:

Memindahkan game multipemain dari C++ ke web dengan Cheerp, WebRTC, dan Firebase
Diagram lengkap fase koneksi antara klien dan server

Kesimpulan

Jika Anda sudah membaca sejauh ini, Anda mungkin tertarik melihat teori ini diterapkan. Permainan dapat dimainkan teeworlds.leaningtech.com, Cobalah!


Pertandingan persahabatan antar rekan kerja

Kode perpustakaan jaringan tersedia secara gratis di Github. Bergabunglah dalam percakapan di saluran kami di Gitter!

Sumber: www.habr.com

Tambah komentar