ProHoster > Օրագիր > Վարչակազմը > C++-ից բազմախաղացող խաղ տեղափոխելը համացանց Cheerp-ի, WebRTC-ի և Firebase-ի միջոցով
C++-ից բազմախաղացող խաղ տեղափոխելը համացանց Cheerp-ի, WebRTC-ի և Firebase-ի միջոցով
Ներածություն
Մեր ընկերությունը Leaning Technologies լուծումներ է տալիս ավանդական աշխատասեղանի հավելվածները համացանց տեղափոխելու համար: Մեր C++ կոմպիլյատորը ուրախանալ առաջացնում է WebAssembly-ի և JavaScript-ի համադրություն, որն ապահովում է երկուսն էլ պարզ բրաուզերի փոխազդեցությունև բարձր կատարողականություն։
Որպես դրա կիրառման օրինակ՝ մենք որոշեցինք մի քանի խաղացող խաղ տեղափոխել համացանց և ընտրեցինք Թեորվերդսս. Teeworlds-ը բազմախաղացող XNUMXD ռետրո խաղ է խաղացողների փոքր, բայց ակտիվ համայնքով (ներառյալ ես): Այն փոքր է թե՛ ներբեռնված ռեսուրսների, թե՛ պրոցեսորի և պրոցեսորի պահանջների առումով՝ իդեալական թեկնածու:
Աշխատում է Teeworlds բրաուզերում
Մենք որոշեցինք օգտագործել այս նախագիծը փորձերի համար ընդհանուր լուծումներ ցանցի կոդը վեբ տեղափոխելու համար. Դա սովորաբար արվում է հետևյալ եղանակներով.
XMLHttpՊահանջել/բերել, եթե ցանցի մասը բաղկացած է միայն HTTP հարցումներից, կամ
WebSockets.
Երկու լուծումներն էլ պահանջում են սերվերի բաղադրիչի տեղադրում սերվերի կողմից, և ոչ մեկը թույլ չի տալիս օգտագործել որպես տրանսպորտային արձանագրություն UDP. Սա կարևոր է իրական ժամանակի ծրագրերի համար, ինչպիսիք են վիդեոկոնֆերանսների ծրագրակազմը և խաղերը, քանի որ երաշխավորում է արձանագրության փաթեթների առաքումն ու կարգը: TCP կարող է խոչընդոտ դառնալ ցածր հետաձգման համար:
Կա երրորդ ճանապարհ՝ օգտագործեք ցանցը զննարկիչից. WebRTC- ն.
RTCDataChannel Այն աջակցում է ինչպես հուսալի, այնպես էլ անվստահելի փոխանցում (վերջին դեպքում այն փորձում է օգտագործել UDP որպես տրանսպորտային արձանագրություն, երբ հնարավոր է), և կարող է օգտագործվել ինչպես հեռավոր սերվերի, այնպես էլ բրաուզերների միջև: Սա նշանակում է, որ մենք կարող ենք ամբողջ հավելվածը տեղափոխել զննարկիչ, ներառյալ սերվերի բաղադրիչը:
Այնուամենայնիվ, սա գալիս է լրացուցիչ դժվարությամբ. մինչև WebRTC երկու հասակակիցները կարողանան հաղորդակցվել, նրանք պետք է կատարեն համեմատաբար բարդ ձեռքսեղմում միանալու համար, որը պահանջում է մի քանի երրորդ կողմի սուբյեկտներ (ազդանշանային սերվեր և մեկ կամ ավելի սերվերներ): ԱՇԽԱՏԵԼ/ԿԱՐՈՂ).
Իդեալում, մենք կցանկանայինք ստեղծել ցանցային API, որն օգտագործում է WebRTC-ն ներսից, բայց հնարավորինս մոտ է UDP Sockets ինտերֆեյսին, որը կապ հաստատելու կարիք չունի:
Սա թույլ կտա մեզ օգտվել WebRTC-ից՝ առանց հավելվածի կոդը բացահայտելու բարդ մանրամասներ (որը մենք ցանկանում էինք հնարավորինս քիչ փոխել մեր նախագծում):
Նվազագույն WebRTC
WebRTC-ն բրաուզերներում հասանելի API-ների մի շարք է, որն ապահովում է աուդիո, վիդեո և կամայական տվյալների հավասարակցական փոխանցում:
Հասակակիցների միջև կապը հաստատվում է (նույնիսկ եթե կա NAT մեկ կամ երկու կողմերում) STUN և/կամ TURN սերվերների միջոցով ICE կոչվող մեխանիզմի միջոցով: Գործընկերները փոխանակում են ICE տեղեկատվությունը և ալիքի պարամետրերը SDP արձանագրության առաջարկի և պատասխանի միջոցով:
Վա՜յ։ Քանի՞ հապավում է միաժամանակ: Եկեք համառոտ բացատրենք, թե ինչ են նշանակում այս տերմինները.
Session Traversal Utilities NAT-ի համար (ԱՇԽԱՏԵԼ) — արձանագրություն՝ NAT-ը շրջանցելու և զույգ (IP, պորտ) ստանալու համար՝ ուղղակիորեն հյուրընկալողի հետ տվյալների փոխանակման համար: Եթե նրան հաջողվի կատարել իր առաջադրանքը, ապա հասակակիցները կարող են ինքնուրույն փոխանակել տվյալներ միմյանց հետ:
Անցում, օգտագործելով ռելեներ NAT-ի շուրջ (ԿԱՐՈՂ) օգտագործվում է նաև NAT երթևեկության համար, սակայն այն իրականացնում է դա՝ փոխանցելով տվյալները վստահված անձի միջոցով, որը տեսանելի է երկու գործընկերների համար: Այն ավելացնում է ուշացում և ավելի թանկ է իրագործումը, քան STUN-ը (քանի որ այն կիրառվում է հաղորդակցության ողջ նիստի ընթացքում), բայց երբեմն դա միակ տարբերակն է:
Ինտերակտիվ կապի հաստատում (ICE) օգտագործվում է երկու հասակակիցների միացման լավագույն հնարավոր մեթոդը ընտրելու համար՝ հիմնված ուղղակիորեն հասակակիցների միացումից ստացված տեղեկատվության, ինչպես նաև ցանկացած թվով STUN և TURN սերվերների կողմից ստացված տեղեկատվության վրա:
Նստաշրջանի նկարագրության արձանագրություն (SDP- ն) միացման ալիքի պարամետրերը նկարագրելու ձևաչափ է, օրինակ՝ ICE թեկնածուներ, մուլտիմեդիա կոդեկներ (աուդիո/վիդեո ալիքի դեպքում) և այլն... Գործընկերներից մեկն ուղարկում է SDP Առաջարկ, իսկ երկրորդը պատասխանում է SDP պատասխանով։ . Դրանից հետո ստեղծվում է ալիք։
Նման կապ ստեղծելու համար գործընկերները պետք է հավաքեն STUN և TURN սերվերներից ստացած տեղեկատվությունը և փոխանակեն դրանք միմյանց հետ։
Խնդիրն այն է, որ նրանք դեռ ուղղակիորեն հաղորդակցվելու հնարավորություն չունեն, ուստի այս տվյալները փոխանակելու համար պետք է գոյություն ունենա տիրույթից դուրս մեխանիզմ՝ ազդանշանային սերվեր:
Ազդանշանային սերվերը կարող է շատ պարզ լինել, քանի որ նրա միակ աշխատանքը ձեռքսեղմման փուլում գտնվող հասակակիցների միջև տվյալների փոխանցումն է (ինչպես ցույց է տրված ստորև ներկայացված դիագրամում):
Հաճախորդը և սերվերի բաղադրիչները երկու տարբեր ծրագրեր են:
Հաճախորդները մտնում են խաղ՝ միանալով մի քանի սերվերներից մեկին, որոնցից յուրաքանչյուրը միաժամանակ ընդունում է միայն մեկ խաղ:
Խաղում բոլոր տվյալների փոխանցումն իրականացվում է սերվերի միջոցով:
Հատուկ գլխավոր սերվերն օգտագործվում է բոլոր հանրային սերվերների ցանկը հավաքելու համար, որոնք ցուցադրվում են խաղի հաճախորդում:
Տվյալների փոխանակման համար WebRTC-ի օգտագործման շնորհիվ մենք կարող ենք խաղի սերվերային բաղադրիչը փոխանցել այն բրաուզերին, որտեղ գտնվում է հաճախորդը: Սա մեզ մեծ հնարավորություն է տալիս...
Ազատվել սերվերներից
Սերվերի տրամաբանության բացակայությունը լավ առավելություն ունի. մենք կարող ենք ամբողջ հավելվածը որպես ստատիկ բովանդակություն տեղակայել Github էջերում կամ Cloudflare-ի ետևում գտնվող մեր սեփական սարքաշարի վրա՝ այդպիսով ապահովելով արագ ներբեռնումներ և բարձր գործունակություն անվճար: Իրականում մենք կարող ենք մոռանալ դրանց մասին, և եթե մեր բախտը բերի, և խաղը հայտնի դառնա, ապա ենթակառուցվածքները արդիականացնելու կարիք չեն ունենա։
Այնուամենայնիվ, որպեսզի համակարգը աշխատի, մենք դեռ պետք է օգտագործենք արտաքին ճարտարապետություն.
Մեկ կամ մի քանի STUN սերվերներ. Մենք ունենք մի քանի անվճար ընտրանքներ:
Առնվազն մեկ TURN սերվեր. այստեղ անվճար տարբերակներ չկան, այնպես որ մենք կարող ենք կամ ստեղծել մերը, կամ վճարել ծառայության համար: Բարեբախտաբար, ժամանակի մեծ մասը կապը կարող է հաստատվել STUN սերվերների միջոցով (և ապահովել իրական p2p), բայց TURN-ն անհրաժեշտ է որպես հետադարձ տարբերակ:
Ազդանշանային սերվեր. Ի տարբերություն մյուս երկու ասպեկտների, ազդանշանը ստանդարտացված չէ: Ինչի համար իրականում պատասխանատու կլինի ազդանշանային սերվերը, որոշ չափով կախված է հավելվածից: Մեր դեպքում կապ հաստատելուց առաջ անհրաժեշտ է փոխանակել փոքր քանակությամբ տվյալներ։
Teeworlds Master Server. Այն օգտագործվում է այլ սերվերների կողմից՝ գովազդելու իրենց գոյությունը, իսկ հաճախորդների կողմից՝ հանրային սերվերներ գտնելու համար: Թեև դա պարտադիր չէ (հաճախորդները միշտ կարող են ձեռքով միանալ սերվերին, որի մասին գիտեն), լավ կլինի, որ խաղացողները կարողանան մասնակցել խաղերին պատահական մարդկանց հետ:
Մենք որոշեցինք օգտագործել Google-ի անվճար STUN սերվերները և ինքներս տեղադրեցինք մեկ TURN սերվեր:
Վերջին երկու կետերի համար մենք օգտագործեցինք Firebase- ը:
Teeworlds-ի գլխավոր սերվերն իրականացվում է շատ պարզ՝ որպես յուրաքանչյուր ակտիվ սերվերի տեղեկատվություն (անուն, IP, քարտեզ, ռեժիմ, ...) պարունակող օբյեկտների ցանկ: Սերվերները հրապարակում և թարմացնում են իրենց սեփական օբյեկտը, իսկ հաճախորդները վերցնում են ամբողջ ցուցակը և ցուցադրում այն նվագարկիչին: Մենք նաև ցուցադրում ենք ցուցակը գլխավոր էջում որպես HTML, որպեսզի խաղացողները պարզապես կարողանան սեղմել սերվերի վրա և տեղափոխվել անմիջապես խաղ:
Ազդանշանավորումը սերտորեն կապված է մեր վարդակների ներդրման հետ, որոնք նկարագրված են հաջորդ բաժնում:
Սերվերների ցանկը խաղի ներսում և գլխավոր էջում
Վարդակների իրականացում
Մենք ցանկանում ենք ստեղծել 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-ների նշանակում և ծույլ կապեր.
Հետադարձ զանգերի գրանցում
Նույնիսկ եթե սկզբնական ծրագիրը օգտագործում է չարգելափակող I/O, կոդը պետք է վերամշակվի, որպեսզի գործարկվի վեբ բրաուզերում:
Դրա պատճառն այն է, որ բրաուզերում իրադարձությունների հանգույցը թաքնված է ծրագրից (լինի դա 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 հանձնարարություն
Մեր «ցանցում» հանգույցների ID-ները 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-ում, և սերվերը պատասխանում է այնտեղ իր պատասխանով:
Ստորև բերված դիագրամը ցույց է տալիս հաղորդագրության հոսքի օրինակ վարդակից սխեմայի և հաճախորդից սերվերին առաջին հաղորդագրության փոխանցման համար.
Հաճախորդի և սերվերի միջև կապի փուլի ամբողջական դիագրամ
Ամփոփում
Եթե այսքան հեռու եք կարդացել, հավանաբար ձեզ հետաքրքրում է տեսությունը գործողության մեջ տեսնել: Խաղը կարելի է խաղալ teeworlds.leaningtech.com, փորձիր!
Ընկերական հանդիպում գործընկերների միջև
Ցանցային գրադարանի կոդը անվճար հասանելի է Github. Միացե՛ք մեր ալիքի զրույցին Գիտեր!