Pag-port ng multiplayer na laro mula sa C++ papunta sa web gamit ang Cheerp, WebRTC at Firebase

Pagpapakilala

aming kompanya Leaning Technologies nagbibigay ng mga solusyon para sa pag-port ng mga tradisyonal na desktop application sa web. Ang aming C++ compiler cheerp bumubuo ng kumbinasyon ng WebAssembly at JavaScript, na nagbibigay ng pareho simpleng pakikipag-ugnayan sa browser, at mataas na pagganap.

Bilang halimbawa ng application nito, nagpasya kaming mag-port ng multiplayer na laro sa web at pumili Teeworlds. Ang Teeworlds ay isang multiplayer XNUMXD retro game na may maliit ngunit aktibong komunidad ng mga manlalaro (kabilang ako!). Maliit ito pareho sa mga na-download na mapagkukunan at mga kinakailangan ng CPU at GPU - isang mainam na kandidato.

Pag-port ng multiplayer na laro mula sa C++ papunta sa web gamit ang Cheerp, WebRTC at Firebase
Tumatakbo sa browser ng Teeworlds

Nagpasya kaming gamitin ang proyektong ito para mag-eksperimento pangkalahatang solusyon para sa pag-port ng network code sa web. Ito ay karaniwang ginagawa sa mga sumusunod na paraan:

  • XMLHttpRequest/fetch, kung ang bahagi ng network ay binubuo lamang ng mga kahilingan sa HTTP, o
  • Mga WebSocket.

Ang parehong mga solusyon ay nangangailangan ng pagho-host ng isang bahagi ng server sa gilid ng server, at hindi pinapayagan para sa paggamit bilang isang transport protocol UDP. Mahalaga ito para sa mga real-time na application tulad ng video conferencing software at mga laro, dahil ginagarantiyahan nito ang paghahatid at pagkakasunud-sunod ng mga protocol packet. TCP maaaring maging hadlang sa mababang latency.

Mayroong pangatlong paraan - gamitin ang network mula sa browser: WebRTC.

RTCDataChannel Sinusuportahan nito ang parehong maaasahan at hindi mapagkakatiwalaan na paghahatid (sa huling kaso ay sinusubukan nitong gamitin ang UDP bilang transport protocol hangga't maaari), at maaaring magamit kapwa sa isang malayong server at sa pagitan ng mga browser. Nangangahulugan ito na maaari naming i-port ang buong application sa browser, kabilang ang bahagi ng server!

Gayunpaman, may karagdagang kahirapan ito: bago makapag-usap ang dalawang WebRTC peer, kailangan nilang magsagawa ng medyo kumplikadong handshake para kumonekta, na nangangailangan ng ilang third-party na entity (isang signaling server at isa o higit pang server STUN/LUMIKO).

Sa isip, gusto naming gumawa ng network API na gumagamit ng WebRTC sa loob, ngunit mas malapit hangga't maaari sa isang interface ng UDP Sockets na hindi kailangang magtatag ng koneksyon.

Ito ay magpapahintulot sa amin na samantalahin ang WebRTC nang hindi kinakailangang ilantad ang mga kumplikadong detalye sa code ng aplikasyon (na gusto naming baguhin nang kaunti hangga't maaari sa aming proyekto).

Pinakamababang WebRTC

Ang WebRTC ay isang hanay ng mga API na available sa mga browser na nagbibigay ng peer-to-peer na pagpapadala ng audio, video at arbitrary na data.

Ang koneksyon sa pagitan ng mga kapantay ay itinatag (kahit na mayroong NAT sa isa o magkabilang panig) gamit ang STUN at/o TURN server sa pamamagitan ng mekanismong tinatawag na ICE. Ang mga kapantay ay nagpapalitan ng impormasyon ng ICE at mga parameter ng channel sa pamamagitan ng alok at sagot ng SDP protocol.

Wow! Ilang abbreviation sa isang pagkakataon? Ipaliwanag natin nang maikli kung ano ang ibig sabihin ng mga terminong ito:

  • Mga Session Traversal Utility para sa NAT (STUN) β€” isang protocol para sa pag-bypass sa NAT at pagkuha ng isang pares (IP, port) para sa direktang pakikipagpalitan ng data sa host. Kung siya ay namamahala upang makumpleto ang kanyang gawain, pagkatapos ay ang mga kapantay ay maaaring malayang makipagpalitan ng data sa bawat isa.
  • Traversal Gamit ang mga Relay sa paligid ng NAT (LUMIKO) ay ginagamit din para sa NAT traversal, ngunit ipinapatupad ito sa pamamagitan ng pagpapasa ng data sa pamamagitan ng isang proxy na nakikita ng parehong mga kapantay. Nagdaragdag ito ng latency at mas mahal na ipatupad kaysa sa STUN (dahil inilapat ito sa buong session ng komunikasyon), ngunit minsan ito lang ang opsyon.
  • Interactive Connectivity Establishment (Yelo) ginamit upang piliin ang pinakamahusay na posibleng paraan ng pagkonekta ng dalawang peer batay sa impormasyong nakuha mula sa direktang pagkonekta ng mga peer, pati na rin ang impormasyong natanggap ng anumang bilang ng mga STUN at TURN server.
  • Protocol ng Paglalarawan ng Session (SDP) ay isang format para sa paglalarawan ng mga parameter ng channel ng koneksyon, halimbawa, mga kandidato sa ICE, mga multimedia codec (sa kaso ng isang audio/video channel), atbp... Ang isa sa mga kapantay ay nagpapadala ng Alok ng SDP, at ang pangalawa ay tumugon sa isang SDP na Sagot .. Pagkatapos nito, gagawa ng channel.

Upang lumikha ng gayong koneksyon, kailangan ng mga kapantay na kolektahin ang impormasyong natatanggap nila mula sa mga server ng STUN at TURN at ipagpalit ito sa isa't isa.

Ang problema ay wala pa silang kakayahang makipag-usap nang direkta, kaya dapat mayroong isang out-of-band na mekanismo upang palitan ang data na ito: isang signaling server.

Ang isang signaling server ay maaaring maging napaka-simple dahil ang tanging trabaho nito ay ang pagpapasa ng data sa pagitan ng mga kapantay sa yugto ng handshake (tulad ng ipinapakita sa diagram sa ibaba).

Pag-port ng multiplayer na laro mula sa C++ papunta sa web gamit ang Cheerp, WebRTC at Firebase
Pinasimpleng WebRTC handshake sequence diagram

Pangkalahatang-ideya ng Modelo ng Teeworlds Network

Ang arkitektura ng network ng Teeworlds ay napaka-simple:

  • Ang mga bahagi ng kliyente at server ay dalawang magkaibang programa.
  • Ang mga kliyente ay pumapasok sa laro sa pamamagitan ng pagkonekta sa isa sa ilang mga server, na ang bawat isa ay nagho-host lamang ng isang laro sa bawat pagkakataon.
  • Ang lahat ng paglilipat ng data sa laro ay isinasagawa sa pamamagitan ng server.
  • Ang isang espesyal na master server ay ginagamit upang mangolekta ng isang listahan ng lahat ng mga pampublikong server na ipinapakita sa client ng laro.

Salamat sa paggamit ng WebRTC para sa pagpapalitan ng data, maaari naming ilipat ang bahagi ng server ng laro sa browser kung saan matatagpuan ang kliyente. Nagbibigay ito sa atin ng magandang pagkakataon...

Alisin ang mga server

Ang kakulangan ng lohika ng server ay may magandang kalamangan: maaari naming i-deploy ang buong application bilang static na nilalaman sa Mga Pahina ng Github o sa aming sariling hardware sa likod ng Cloudflare, kaya tinitiyak ang mabilis na pag-download at mataas na oras ng pag-andar nang libre. Sa katunayan, maaari nating kalimutan ang tungkol sa mga ito, at kung tayo ay mapalad at ang laro ay magiging popular, kung gayon ang imprastraktura ay hindi na kailangang gawing moderno.

Gayunpaman, para gumana ang system, kailangan pa rin nating gumamit ng panlabas na arkitektura:

  • Isa o higit pang mga STUN server: Mayroon kaming ilang mga libreng opsyon na mapagpipilian.
  • Hindi bababa sa isang TURN server: walang mga libreng opsyon dito, kaya maaari naming i-set up ang sarili namin o magbayad para sa serbisyo. Sa kabutihang palad, kadalasan ang koneksyon ay maaaring maitatag sa pamamagitan ng mga server ng STUN (at magbigay ng totoong p2p), ngunit kailangan ang TURN bilang opsyon sa pagbabalik.
  • Server ng Pagsenyas: Hindi tulad ng iba pang dalawang aspeto, ang pagsenyas ay hindi pamantayan. Kung ano talaga ang magiging pananagutan ng signaling server ay medyo depende sa application. Sa aming kaso, bago magtatag ng isang koneksyon, ito ay kinakailangan upang makipagpalitan ng isang maliit na halaga ng data.
  • Teeworlds Master Server: Ito ay ginagamit ng ibang mga server upang i-advertise ang kanilang pag-iral at ng mga kliyente upang maghanap ng mga pampublikong server. Bagama't hindi ito kinakailangan (ang mga kliyente ay maaaring palaging kumonekta sa isang server na alam nila tungkol sa mano-mano), mainam na magkaroon ito upang ang mga manlalaro ay makasali sa mga laro na may mga random na tao.

Nagpasya kaming gamitin ang mga libreng STUN server ng Google, at nag-deploy kami ng isang TURN server.

Para sa huling dalawang puntos na ginamit namin Firebase:

  • Ang master server ng Teeworlds ay ipinatupad nang napakasimple: bilang isang listahan ng mga bagay na naglalaman ng impormasyon (pangalan, IP, mapa, mode, ...) ng bawat aktibong server. Ang mga server ay nag-publish at nag-a-update ng kanilang sariling bagay, at ang mga kliyente ay kumukuha ng buong listahan at ipinapakita ito sa player. Ipinapakita rin namin ang listahan sa home page bilang HTML upang ang mga manlalaro ay mag-click lamang sa server at madala nang diretso sa laro.
  • Ang pagsenyas ay malapit na nauugnay sa aming pagpapatupad ng mga socket, na inilarawan sa susunod na seksyon.

Pag-port ng multiplayer na laro mula sa C++ papunta sa web gamit ang Cheerp, WebRTC at Firebase
Listahan ng mga server sa loob ng laro at sa home page

Pagpapatupad ng mga socket

Gusto naming gumawa ng API na mas malapit sa Posix UDP Sockets hangga't maaari upang mabawasan ang bilang ng mga pagbabagong kailangan.

Nais din naming ipatupad ang kinakailangang minimum na kinakailangan para sa pinakasimpleng pagpapalitan ng data sa network.

Halimbawa, hindi namin kailangan ng totoong pagruruta: ang lahat ng mga peer ay nasa parehong "virtual LAN" na nauugnay sa isang partikular na instance ng database ng Firebase.

Samakatuwid, hindi namin kailangan ng mga natatanging IP address: sapat na ang mga natatanging halaga ng key ng Firebase (katulad ng mga domain name) upang natatanging makilala ang mga peer, at lokal na nagtatalaga ang bawat peer ng "pekeng" mga IP address sa bawat key na kailangang isalin. Ito ay ganap na nag-aalis ng pangangailangan para sa pandaigdigang pagtatalaga ng IP address, na isang hindi maliit na gawain.

Narito ang minimum na API na kailangan nating ipatupad:

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

Ang API ay simple at katulad ng Posix Sockets API, ngunit may ilang mahahalagang pagkakaiba: pag-log callback, pagtatalaga ng mga lokal na IP, at tamad na koneksyon.

Pagrerehistro ng Mga Callback

Kahit na ang orihinal na programa ay gumagamit ng hindi naka-block na I/O, ang code ay dapat na refactored upang tumakbo sa isang web browser.

Ang dahilan nito ay ang loop ng kaganapan sa browser ay nakatago mula sa programa (maging ito ay JavaScript o WebAssembly).

Sa katutubong kapaligiran maaari tayong magsulat ng code na tulad nito

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

Kung nakatago sa amin ang loop ng kaganapan, kailangan namin itong gawing katulad nito:

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

Lokal na pagtatalaga ng IP

Ang mga node ID sa aming "network" ay hindi mga IP address, ngunit ang mga key ng Firebase (ang mga ito ay mga string na ganito ang hitsura: -LmEC50PYZLCiCP-vqde ).

Maginhawa ito dahil hindi namin kailangan ng mekanismo para sa pagtatalaga ng mga IP at pagsuri sa pagiging natatangi ng mga ito (pati na rin ang pagtatapon ng mga ito pagkatapos magdiskonekta ang kliyente), ngunit madalas na kinakailangan upang matukoy ang mga kapantay sa pamamagitan ng isang numeric na halaga.

Ito mismo ang ginagamit ng mga function. resolve ΠΈ reverseResolve: Ang application sa paanuman ay natatanggap ang string value ng key (sa pamamagitan ng user input o sa pamamagitan ng master server), at maaaring i-convert ito sa isang IP address para sa panloob na paggamit. Ang natitirang bahagi ng API ay tumatanggap din ng halagang ito sa halip na isang string para sa pagiging simple.

Ito ay katulad ng DNS lookup, ngunit gumanap nang lokal sa kliyente.

Ibig sabihin, hindi maibabahagi ang mga IP address sa pagitan ng iba't ibang kliyente, at kung kailangan ng ilang uri ng pandaigdigang identifier, kakailanganin itong mabuo sa ibang paraan.

Tamad na koneksyon

Hindi kailangan ng UDP ng koneksyon, ngunit tulad ng nakita natin, nangangailangan ang WebRTC ng mahabang proseso ng koneksyon bago ito makapagsimulang maglipat ng data sa pagitan ng dalawang peer.

Kung gusto naming magbigay ng parehong antas ng abstraction, (sendto/recvfrom na may di-makatwirang mga kapantay na walang paunang koneksyon), pagkatapos ay dapat silang magsagawa ng "tamad" (naantala) na koneksyon sa loob ng API.

Ito ang nangyayari sa normal na komunikasyon sa pagitan ng "server" at ng "client" kapag gumagamit ng UDP, at kung ano ang dapat gawin ng aming library:

  • Mga tawag ng server bind()upang sabihin sa operating system na nais nitong makatanggap ng mga packet sa tinukoy na port.

Sa halip, mag-publish kami ng bukas na port sa Firebase sa ilalim ng server key at makikinig kami ng mga kaganapan sa subtree nito.

  • Mga tawag ng server recvfrom(), tumatanggap ng mga packet na nagmumula sa anumang host sa port na ito.

Sa aming kaso, kailangan naming suriin ang papasok na pila ng mga packet na ipinadala sa port na ito.

Ang bawat port ay may sarili nitong pila, at idinaragdag namin ang source at destination port sa simula ng WebRTC datagrams para malaman namin kung aling pila ang ipapasa kapag may dumating na bagong packet.

Ang tawag ay non-blocking, kaya kung walang mga packet, ibabalik lang namin ang -1 at itakda errno=EWOULDBLOCK.

  • Natatanggap ng kliyente ang IP at port ng server sa pamamagitan ng ilang panlabas na paraan, at mga tawag sendto(). Gumagawa din ito ng panloob na tawag. bind(), samakatuwid ay kasunod recvfrom() ay makakatanggap ng tugon nang hindi tahasang nagsasagawa ng bind.

Sa aming kaso, panlabas na natatanggap ng kliyente ang string key at ginagamit ang function resolve() para makakuha ng IP address.

Sa puntong ito, magsisimula kami ng WebRTC handshake kung hindi pa konektado ang dalawang peer sa isa't isa. Ang mga koneksyon sa iba't ibang port ng parehong peer ay gumagamit ng parehong WebRTC DataChannel.

Gumaganap din kami ng hindi direkta bind()para makakonekta muli ang server sa susunod sendto() kung sakaling nagsara ito para sa ilang kadahilanan.

Inaabisuhan ang server tungkol sa koneksyon ng kliyente kapag isinulat ng kliyente ang alok nito sa SDP sa ilalim ng impormasyon ng port ng server sa Firebase, at tumugon ang server kasama ang tugon nito doon.

Ang diagram sa ibaba ay nagpapakita ng isang halimbawa ng daloy ng mensahe para sa isang socket scheme at ang pagpapadala ng unang mensahe mula sa kliyente patungo sa server:

Pag-port ng multiplayer na laro mula sa C++ papunta sa web gamit ang Cheerp, WebRTC at Firebase
Kumpletuhin ang diagram ng yugto ng koneksyon sa pagitan ng kliyente at server

Konklusyon

Kung nabasa mo na ito, malamang na interesado kang makita ang teorya sa pagkilos. Ang laro ay maaaring i-play sa teeworlds.leaningtech.com, subukan mo!


Friendly match sa pagitan ng mga kasamahan

Ang network library code ay malayang magagamit sa Github. Sumali sa pag-uusap sa aming channel sa parilya!

Pinagmulan: www.habr.com

Magdagdag ng komento