Пренесување игра со повеќе играчи од C++ на интернет со Cheerp, WebRTC и Firebase

Вовед

нашата компанија Насочени технологии обезбедува решенија за пренесување традиционални десктоп апликации на веб. Нашиот C++ компајлер расположи генерира комбинација од WebAssembly и JavaScript, што ги обезбедува и двете едноставна интеракција со прелистувачот, и високи перформанси.

Како пример за нејзината примена, решивме да пренесеме игра со повеќе играчи на веб и избравме Teeworlds. Teeworlds е мултиплеер XNUMXD ретро игра со мала, но активна заедница на играчи (вклучувајќи ме и мене!). Мал е и во однос на преземените ресурси и барањата за процесорот и графичкиот процесор - идеален кандидат.

Пренесување игра со повеќе играчи од C++ на интернет со Cheerp, WebRTC и Firebase
Работи во прелистувачот Teeworlds

Решивме да го искористиме овој проект за експериментирање општи решенија за пренесување мрежен код на веб. Ова обично се прави на следниве начини:

  • XMLHttpБарање/земи, ако мрежниот дел се состои само од барања за HTTP, или
  • Веб -приклучоци.

Двете решенија бараат хостирање на серверска компонента на страната на серверот и ниту едно не дозволува употреба како транспортен протокол UDP. Ова е важно за апликации во реално време, како софтвер за видео конференции и игри, бидејќи гарантира испорака и редослед на протоколски пакети TCP може да стане пречка за мала латентност.

Постои трет начин - користете ја мрежата од прелистувачот: WebRTC.

RTCDataChannel Поддржува и сигурен и несигурен пренос (во вториот случај се обидува да користи UDP како транспортен протокол секогаш кога е можно), и може да се користи и со оддалечен сервер и помеѓу прелистувачи. Ова значи дека можеме да ја пренесеме целата апликација на прелистувачот, вклучувајќи ја и серверската компонента!

Сепак, ова доаѓа со дополнителна тешкотија: пред да можат да комуницираат двајца колеги од WebRTC, тие треба да извршат релативно сложено ракување за да се поврзат, за што се потребни неколку ентитети од трета страна (сервер за сигнализација и еден или повеќе сервери ЗАПРЕДУВАЊЕ/ВРЕМЕ).

Идеално, би сакале да создадеме мрежен API што користи WebRTC внатрешно, но е што е можно поблиску до интерфејсот на UDP Sockets што не треба да воспоставува врска.

Ова ќе ни овозможи да ги искористиме предностите на WebRTC без да мора да изложуваме сложени детали на кодот на апликацијата (што сакавме да го промениме што е можно помалку во нашиот проект).

Минимален WebRTC

WebRTC е збир на API достапни во прелистувачите кои обезбедуваат пренос од peer-to-peer на аудио, видео и произволни податоци.

Врската помеѓу врсниците е воспоставена (дури и ако има NAT на едната или на двете страни) со помош на STUN и/или TURN сервери преку механизам наречен ICE. Врсниците разменуваат информации за ICE и параметрите на каналот преку понуда и одговор на протоколот SDP.

Леле! Колку кратенки во исто време? Ајде накратко да објасниме што значат овие поими:

  • Комунални услуги за поминување на сесии за NAT (ЗАПРЕДУВАЊЕ) — протокол за заобиколување на NAT и добивање на пар (IP, порта) за размена на податоци директно со домаќинот. Ако успее да ја заврши својата задача, тогаш врсниците можат самостојно да разменуваат податоци едни со други.
  • Преминување со помош на релеи околу NAT (ВРЕМЕ) се користи и за NAT-преминување, но тоа го имплементира со препраќање податоци преку прокси што е видлив за двата врсници. Додава латентност и е поскапо за имплементација од STUN (бидејќи се применува во текот на целата комуникациска сесија), но понекогаш тоа е единствената опција.
  • Воспоставување интерактивна поврзаност (ICE) се користи за да се избере најдобриот можен метод за поврзување на две врсници врз основа на информациите добиени од директно поврзување на врсниците, како и информациите добиени од кој било број STUN и TURN сервери.
  • Протокол за опис на сесија (СДП) е формат за опишување параметри на каналот за поврзување, на пример, кандидати за ICE, мултимедијални кодеци (во случај на аудио/видео канал) итн... Еден од колегите испраќа SDP понуда, а вториот одговара со SDP одговор . По ова, се создава канал.

За да се создаде таква врска, врсниците треба да ги соберат информациите што ги добиваат од серверите STUN и TURN и да ги разменат меѓу себе.

Проблемот е што тие сè уште немаат можност да комуницираат директно, така што мора да постои механизам надвор од опсегот за размена на овие податоци: сигнален сервер.

Серверот за сигнализација може да биде многу едноставен затоа што неговата единствена работа е препраќање податоци помеѓу врсниците во фазата на ракување (како што е прикажано на дијаграмот подолу).

Пренесување игра со повеќе играчи од C++ на интернет со Cheerp, WebRTC и Firebase
Поедноставен дијаграм на секвенца на ракување со WebRTC

Преглед на моделот на мрежата Teeworlds

Архитектурата на мрежата на Teeworlds е многу едноставна:

  • Компонентите на клиентот и серверот се две различни програми.
  • Клиентите влегуваат во играта со поврзување на еден од неколкуте сервери, од кои секој е домаќин на само една игра во исто време.
  • Целиот пренос на податоци во играта се врши преку серверот.
  • Специјален главен сервер се користи за собирање листа на сите јавни сервери што се прикажани во клиентот на играта.

Благодарение на употребата на WebRTC за размена на податоци, можеме да ја пренесеме серверската компонента на играта на прелистувачот каде што се наоѓа клиентот. Ова ни дава одлична можност...

Ослободете се од серверите

Недостатокот на логика на серверот има убава предност: можеме да ја распоредиме целата апликација како статична содржина на страниците на Github или на нашиот сопствен хардвер зад Cloudflare, со што ќе обезбедиме брзо преземање и високо време на работа бесплатно. Всушност, можеме да заборавиме на нив, а ако имаме среќа и играта стане популарна, тогаш инфраструктурата нема да мора да се модернизира.

Сепак, за системот да работи, сè уште треба да користиме надворешна архитектура:

  • Еден или повеќе STUN сервери: Имаме неколку бесплатни опции за избор.
  • Најмалку еден TURN сервер: тука нема бесплатни опции, па можеме или да поставиме свој или да платиме за услугата. За среќа, најчесто врската може да се воспостави преку STUN серверите (и да обезбеди вистински p2p), но TURN е потребен како резервна опција.
  • Сервер за сигнализација: За разлика од другите два аспекта, сигнализацијата не е стандардизирана. За што всушност ќе биде одговорен серверот за сигнализација, донекаде зависи од апликацијата. Во нашиот случај, пред да се воспостави врска, неопходно е да се размени мала количина на податоци.
  • Teeworlds Master Server: Се користи од други сервери за да го рекламираат своето постоење и од клиентите за да најдат јавни сервери. Иако тоа не е потребно (клиентите секогаш можат рачно да се поврзат на сервер за кој знаат), би било убаво да се има за играчите да можат да учествуваат во игри со случајни луѓе.

Решивме да ги користиме бесплатните STUN сервери на Google и самите распоредивме еден TURN сервер.

За последните две точки ги искористивме Firebase:

  • Главниот сервер Teeworlds се имплементира многу едноставно: како листа на објекти кои содржат информации (име, IP, мапа, режим, ...) за секој активен сервер. Серверите објавуваат и ажурираат свој објект, а клиентите ја земаат целата листа и ја прикажуваат на плеерот. Исто така, го прикажуваме списокот на почетната страница како HTML, така што играчите можат едноставно да кликнат на серверот и да бидат однесени директно во играта.
  • Сигнализацијата е тесно поврзана со нашата имплементација на приклучоци, опишана во следниот дел.

Пренесување игра со повеќе играчи од C++ на интернет со Cheerp, WebRTC и Firebase
Список на сервери во играта и на почетната страница

Имплементација на сокети

Сакаме да создадеме API што е што е можно поблиску до Posix UDP Sockets за да го минимизираме бројот на потребни промени.

Исто така, сакаме да го имплементираме потребниот минимум потребен за наједноставна размена на податоци преку мрежата.

На пример, не ни треба вистинско рутирање: сите врсници се на иста „виртуелна LAN“ поврзана со специфичен пример на база на податоци на Firebase.

Затоа, не ни требаат единствени IP адреси: единствените вредности на клучевите на Firebase (слични на имињата на домени) се доволни за уникатно да се идентификуваат врсниците, а секој врсник локално доделува „лажни“ IP адреси на секој клуч што треба да се преведе. Ова целосно ја елиминира потребата за доделување глобална IP адреса, што е нетривијална задача.

Еве го минималниот 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 е едноставен и сличен на Posix Sockets API, но има неколку важни разлики: пријавување повратни повици, доделување локални IP адреси и мрзливи врски.

Регистрирање повратни повици

Дури и ако оригиналната програма користи В/И без блокирање, кодот мора да се рефакторира за да работи во веб-прелистувач.

Причината за ова е што циклусот на настани во прелистувачот е скриен од програмата (било да е JavaScript или WebAssembly).

Во домашната средина можеме да напишеме вака код

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

Ако циклусот на настани е скриен за нас, тогаш треба да го претвориме во нешто како ова:

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

Доделување на локална IP адреса

ИД на јазлите во нашата „мрежа“ не се IP адреси, туку клучеви на Firebase (тие се низи што изгледаат вака: -LmEC50PYZLCiCP-vqde ).

Ова е погодно затоа што не ни треба механизам за доделување IP-адреса и проверка на нивната единственост (како и нивно отстранување по исклучувањето на клиентот), но често е неопходно да се идентификуваат врсниците со нумеричка вредност.

Токму за ова се користат функциите. resolve и reverseResolve: Апликацијата некако ја прима вредноста на стрингот на клучот (преку кориснички внес или преку главниот сервер) и може да ја претвори во IP адреса за внатрешна употреба. Остатокот од API ја добива оваа вредност наместо низа за едноставност.

Ова е слично на пребарувањето DNS, но се врши локално на клиентот.

Односно, IP-адресите не можат да се споделат помеѓу различни клиенти и ако е потребен некој вид на глобален идентификатор, тој ќе мора да се генерира на поинаков начин.

Мрзелива врска

На UDP не му е потребна врска, но како што видовме, WebRTC бара долг процес на поврзување пред да може да започне со пренос на податоци помеѓу два врсници.

Ако сакаме да го обезбедиме истото ниво на апстракција, (sendto/recvfrom со произволни врсници без претходна врска), тогаш тие мора да извршат „мрзлива“ (одложена) врска внатре во API.

Ова е она што се случува при нормална комуникација помеѓу „серверот“ и „клиентот“ при користење на UDP и што треба да направи нашата библиотека:

  • Повици на серверот bind()да му каже на оперативниот систем дека сака да прима пакети на наведената порта.

Наместо тоа, ќе објавиме отворена порта за Firebase под клучот на серверот и ќе слушаме настани во неговото подстебло.

  • Повици на серверот recvfrom(), прифаќајќи пакети кои доаѓаат од кој било домаќин на оваа порта.

Во нашиот случај, треба да ја провериме дојдовната редица на пакети испратени до оваа порта.

Секоја порта има своја редица и ги додаваме портите за извор и одредиште на почетокот на датаграмите на WebRTC за да знаеме до која редица да препратиме кога ќе пристигне нов пакет.

Повикот е неблокирачки, па ако нема пакети, едноставно враќаме -1 и поставуваме errno=EWOULDBLOCK.

  • Клиентот ја прима IP адресата и портата на серверот со некои надворешни средства и повикува sendto(). Ова исто така прави внатрешен повик. bind(), затоа последователно recvfrom() ќе го добие одговорот без експлицитно извршување врзување.

Во нашиот случај, клиентот надворешно го прима стрингот и ја користи функцијата resolve() за да добиете IP адреса.

Во овој момент, ние иницираме ракување со WebRTC ако двата врсници сè уште не се поврзани еден со друг. Поврзувањата со различни порти на ист врсник го користат истиот WebRTC DataChannel.

Вршиме и индиректни bind()за да може серверот повторно да се поврзе во следниот sendto() во случај да се затвори поради некоја причина.

Серверот е известен за врската на клиентот кога клиентот ќе ја запише својата SDP понуда под информациите за серверската порта во Firebase, а серверот одговара со својот одговор таму.

Дијаграмот подолу покажува пример за проток на пораки за шема со сокет и пренос на првата порака од клиентот до серверот:

Пренесување игра со повеќе играчи од C++ на интернет со Cheerp, WebRTC и Firebase
Целосен дијаграм на фазата на поврзување помеѓу клиентот и серверот

Заклучок

Ако сте прочитале досега, веројатно сте заинтересирани да ја видите теоријата на дело. Играта може да се игра на teeworlds.leaningtech.com, пробај!


Пријателски натпревар меѓу колегите

Кодот на мрежната библиотека е слободно достапен на Github. Придружете се на разговорот на нашиот канал на Појак!

Извор: www.habr.com

Додадете коментар