Kami port game multi-pemain dari C ++ ke web dengan Cheerp, WebRTC, dan Firebase

Pendahuluan


Perusahaan kami Leaning Technologies menyediakan solusi untuk porting aplikasi desktop tradisional ke web. Kompiler C ++ Cheerp kami menghasilkan kombinasi WebAssembly dan JavaScript, yang menyediakan interaksi browser yang mudah dan kinerja tinggi.

Sebagai contoh aplikasinya, kami memutuskan untuk port game multi-pemain untuk web dan memilih Teeworlds untuk ini. Teeworlds adalah gim retro multi-pemain, dua dimensi dengan komunitas kecil tetapi pemain aktif (termasuk saya!). Ini kecil dalam hal sumber daya yang dapat diunduh dan persyaratan CPU dan GPU - kandidat yang ideal.


Bekerja di peramban Teeworlds

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

  • XMLHttpRequest / fetch jika bagian jaringan hanya terdiri dari permintaan HTTP, atau
  • Soket Web

Kedua solusi memerlukan hosting komponen server di sisi server, dan tidak ada satupun yang memungkinkan Anda untuk menggunakan UDP sebagai protokol transport. Ini penting untuk aplikasi waktu nyata seperti konferensi video dan perangkat lunak permainan, karena jaminan pengiriman dan pemesanan paket TCP dapat mengganggu latensi rendah.

Ada cara ketiga - gunakan jaringan dari browser: WebRTC .

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

Namun, ini merupakan kesulitan tambahan: sebelum dua rekan WebRTC dapat bertukar data, mereka perlu melakukan prosedur jabat tangan yang relatif rumit untuk menghubungkan, yang memerlukan beberapa entitas pihak ketiga (server sinyal dan satu atau lebih server STUN / MENGHIDUPKAN ).

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

Ini akan memungkinkan kami untuk mengambil keuntungan dari WebRTC tanpa perlu mengungkapkan detail kompleks ke kode aplikasi (yang kami ingin sesedikit mungkin diubah dalam proyek kami).

WebRTC minimum


WebRTC adalah suite API yang tersedia di browser yang menyediakan transfer data audio, video, dan sewenang-wenang.

Koneksi antara rekan-rekan dibuat (bahkan jika ada NAT di satu atau kedua sisi) menggunakan server STUN dan / atau MENGHIDUPKAN melalui mekanisme yang disebut ICE. Peers bertukar informasi ICE dan parameter saluran melalui SDP menawarkan dan menjawab protokol.

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

  • Session Traversal Utilities for NAT ( STUN ) - protokol untuk mem-bypass NAT dan menerima pasangan (IP, port) untuk bertukar data secara langsung dengan host. Jika dia berhasil menyelesaikan tugasnya, maka rekan sejawat dapat saling bertukar data secara independen.
  • Traversal Using Relays around NAT ( TURN ) juga digunakan untuk mem-bypass NAT, tetapi melakukan ini dengan mengarahkan ulang data melalui proxy yang dapat dilihat oleh kedua rekan. Ini menambah penundaan dan lebih mahal untuk dieksekusi daripada STUN (karena digunakan selama sesi komunikasi), tetapi kadang-kadang ini adalah satu-satunya pilihan yang mungkin.
  • Interactive Connectivity Establishment ( ICE ) digunakan untuk memilih cara terbaik untuk menghubungkan dua rekan berdasarkan informasi yang diperoleh dengan langsung menghubungkan rekan, serta informasi yang diterima oleh sejumlah server STUN dan MENGHIDUPKAN.
  • Session Description Protocol ( SDP ) adalah format untuk menggambarkan parameter saluran koneksi, misalnya, kandidat ICE, codec multimedia (dalam hal saluran audio / video), dll. ... Salah satu rekan mengirim Penawaran SDP ("penawaran"), dan yang kedua merespons dengan SDP Jawab ("respons"). Setelah itu, saluran dibuat.

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

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

Server sinyal bisa sangat sederhana, karena satu-satunya tugasnya adalah mengarahkan ulang data di antara rekan-rekan pada tahap "jabat tangan" (seperti yang ditunjukkan pada diagram di bawah).


WebRTC Urutan Jabat Tangan Sederhana

Tinjauan Model Jaringan Teeworlds


Arsitektur jaringan Teeworlds sangat sederhana:

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

Karena 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 menggunakan seluruh aplikasi sebagai konten statis pada Halaman Github atau pada peralatan kita sendiri di balik Cloudflare, sehingga memastikan unduhan yang cepat dan waktu kerja yang tinggi secara gratis. Faktanya, kita bisa melupakannya, dan jika kita beruntung dan permainannya menjadi populer, maka infrastrukturnya tidak harus dimodernisasi.

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

  • Satu atau lebih server STUN: kami memiliki beberapa opsi gratis.
  • Setidaknya satu TURN server: tidak ada opsi gratis di sini, jadi kami dapat mengatur sendiri atau membayar untuk layanan ini. Untungnya, sebagian besar waktu Anda dapat terhubung melalui server STUN (dan memberikan p2p benar), tetapi MENGHIDUPKAN diperlukan sebagai cadangan.
  • Server sinyal: tidak seperti dua aspek lainnya, pensinyalan tidak dibakukan. Server sinyal yang sebenarnya bertanggung jawab tergantung pada aplikasi dalam beberapa cara. Dalam kasus kami, sebelum membuat koneksi, perlu untuk bertukar sejumlah kecil data.
  • Server master Teeworlds: digunakan oleh server lain untuk memberi tahu keberadaannya dan klien untuk mencari server publik. Meskipun tidak diperlukan (klien selalu dapat terhubung ke server yang mereka kenal secara manual), akan menyenangkan untuk memilikinya sehingga pemain dapat berpartisipasi dalam permainan dengan orang-orang acak.

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

Untuk dua poin terakhir kami menggunakan Firebase :

  • Master server Teeworlds diimplementasikan dengan sangat sederhana: sebagai daftar objek yang berisi informasi (nama, IP, peta, mode, ...) dari setiap server yang 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 mengeklik server dan langsung menuju permainan.
  • Signaling terkait erat dengan implementasi soket kami, dijelaskan pada bagian selanjutnya.


Daftar server di dalam gim dan di beranda

Implementasi Soket


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

Kami juga ingin mewujudkan minimum yang diperlukan untuk pertukaran data paling sederhana melalui jaringan.

Misalnya, kita tidak perlu perutean yang nyata: semua rekan berada di "LAN virtual" yang sama yang terkait dengan contoh spesifik dari basis data Firebase.

Oleh karena itu, kami tidak memerlukan alamat IP unik: untuk identifikasi unik rekan, cukup menggunakan nilai unik kunci Firebase (mirip dengan nama domain), dan setiap rekan kerja secara lokal memberikan alamat IP "palsu" ke setiap kunci yang perlu dikonversi. Ini sepenuhnya menghilangkan kebutuhan untuk penugasan alamat IP global, yang merupakan tugas non-sepele.

Berikut ini adalah API minimum yang perlu kami 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, tetapi memiliki beberapa perbedaan penting: mendaftarkan panggilan balik, menetapkan IP lokal, dan koneksi yang malas .

Registrasi Panggilan Balik


Sekalipun program sumber menggunakan I / O non-pemblokiran, kode tersebut harus di refactored untuk dijalankan di browser web.

Alasan untuk ini adalah bahwa loop acara di browser disembunyikan dari program (baik itu JavaScript atau WebAssembly).

Di lingkungan asli, kita dapat menulis kode dengan cara 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 loop acara disembunyikan untuk 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 

Penugasan IP Lokal


Pengidentifikasi node di "jaringan" kami bukan alamat IP, tetapi kunci Firebase (ini adalah garis yang terlihat seperti ini: -LmEC50PYZLCiCP-vqde ).

Ini nyaman karena kami tidak memerlukan mekanisme untuk menetapkan IP dan memeriksa keunikannya (serta pembuangannya setelah memutuskan hubungan klien), tetapi sering kali perlu mengidentifikasi rekan-rekan dengan nilai numerik.

Untuk ini, fungsi reverseResolve dan reverseResolve digunakan: aplikasi entah bagaimana mendapatkan nilai string kunci (melalui input pengguna atau melalui server master), dan dapat mengubahnya menjadi alamat IP untuk penggunaan internal. Sisa API juga mendapatkan nilai ini alih-alih string untuk kesederhanaan.

Ini mirip dengan pencarian DNS, hanya dilakukan secara lokal di klien.

Artinya, alamat IP tidak dapat dibagi antara klien yang berbeda, dan jika Anda memerlukan semacam pengidentifikasi global, Anda harus membuatnya dengan cara yang berbeda.

Campuran malas


UDP tidak membutuhkan koneksi, tetapi, seperti yang kita lihat, sebelum memulai transfer data antara dua rekan, WebRTC membutuhkan proses koneksi yang panjang.

Jika kita ingin memberikan tingkat abstraksi yang sama, ( sendto / recvfrom dengan teman sebaya tanpa menghubungkan terlebih dahulu), maka kita harus membuat koneksi "malas" (tertunda) di dalam API.

Inilah yang terjadi selama pertukaran data normal antara "server" dan "klien" jika menggunakan UDP, dan apa yang harus dilakukan perpustakaan kami:

  • Server memanggil bind() untuk memberi tahu sistem operasi bahwa ia ingin menerima paket ke port yang ditentukan.

Sebagai gantinya, kami akan menerbitkan port terbuka di Firebase di bawah kunci server dan mendengarkan acara di subtree-nya.

  • Server memanggil recvfrom() , menerima paket dari host mana saja ke port ini.

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

Setiap port memiliki antriannya sendiri, dan kami menambahkan sumber dan port tujuan di awal datagram WebRTC untuk mengetahui antrian mana yang akan diarahkan ketika sebuah paket baru tiba.

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

  • Klien menerima, dengan beberapa cara eksternal, IP dan port server, dan panggilan sendto() . Juga, panggilan internal untuk bind() dilakukan, sehingga recvfrom() berikutnya recvfrom() akan menerima respons tanpa secara eksplisit mengeksekusi bind.

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

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

Kami juga melakukan bind() tidak langsung bind() sehingga server dapat menyambung kembali di sendto() berikutnya sendto() jika sendto() karena beberapa alasan.

Server diberi tahu tentang klien yang terhubung ketika klien menulis tawaran SDP-nya di bawah informasi port server di Firebase, dan server merespons dengan responsnya sendiri.



Diagram di bawah ini menunjukkan contoh perpindahan pesan untuk skema soket dan pengiriman pesan pertama dari klien ke server:


Diagram langkah koneksi lengkap antara klien dan server

Kesimpulan


Jika Anda telah membaca sampai akhir, maka Anda mungkin tertarik untuk melihat teori yang sedang beraksi. Gim ini dapat dimainkan di teeworlds.leaningtech.com , cobalah!


Pertandingan persahabatan antara kolega

Kode perpustakaan jaringan tersedia secara bebas di Github . Bergabunglah mengobrol di saluran kami di Gitter !

Source: https://habr.com/ru/post/id468031/


All Articles