ProHoster > وبلاگ > اداره > انتقال یک بازی چند نفره از C++ به وب با Cheerp، WebRTC و Firebase
انتقال یک بازی چند نفره از C++ به وب با Cheerp، WebRTC و Firebase
معرفی
کارخانه ی ما فن آوری های متمایل راه حل هایی برای انتقال برنامه های دسکتاپ سنتی به وب ارائه می دهد. کامپایلر C++ ما خوشحال کردن ترکیبی از WebAssembly و JavaScript را تولید می کند که هر دو را فراهم می کند تعامل ساده با مرورگر، و عملکرد بالا.
به عنوان نمونه ای از کاربرد آن، تصمیم گرفتیم یک بازی چند نفره را به وب پورت کنیم و انتخاب کردیم Teeworlds. Teeworlds یک بازی چندنفره دو بعدی یکپارچهسازی با جمعیت کوچک اما فعال از بازیکنان (از جمله من!) است. هم از نظر منابع دانلود شده و هم از نظر نیازهای CPU و GPU کوچک است - یک نامزد ایده آل.
در حال اجرا در مرورگر Teeworlds
ما تصمیم گرفتیم از این پروژه برای آزمایش استفاده کنیم راه حل های کلی برای انتقال کد شبکه به وب. این کار معمولا به روش های زیر انجام می شود:
XMLHttpRequest/fetch، اگر بخش شبکه فقط از درخواست های HTTP تشکیل شده باشد، یا
صفحات وب.
هر دو راه حل نیاز به میزبانی یک جزء سرور در سمت سرور دارند و هیچ کدام اجازه استفاده به عنوان پروتکل حمل و نقل را نمی دهد UDP. این برای برنامههای بلادرنگ مانند نرمافزارها و بازیهای ویدئو کنفرانس مهم است، زیرا تحویل و سفارش بستههای پروتکل را تضمین میکند. TCP ممکن است مانعی برای تاخیر کم شود.
یک راه سوم وجود دارد - از شبکه از مرورگر استفاده کنید: WebRTC.
RTCDataChannel هم از انتقال قابل اعتماد و هم غیرقابل اعتماد پشتیبانی می کند (در مورد دوم سعی می کند در صورت امکان از UDP به عنوان یک پروتکل انتقال استفاده کند)، و می تواند هم با یک سرور راه دور و هم بین مرورگرها استفاده شود. این بدان معناست که ما می توانیم کل برنامه را به مرورگر پورت کنیم، از جمله مؤلفه سرور!
با این حال، این با یک مشکل اضافی همراه است: قبل از اینکه دو همتای WebRTC بتوانند با هم ارتباط برقرار کنند، باید یک دست دادن نسبتاً پیچیده برای اتصال انجام دهند، که به چندین موجودیت شخص ثالث (یک سرور سیگنالینگ و یک یا چند سرور) نیاز دارد. STUN/دور زدن).
در حالت ایدهآل، ما میخواهیم یک API شبکه ایجاد کنیم که از WebRTC به صورت داخلی استفاده کند، اما تا حد امکان به یک رابط UDP Sockets نزدیک باشد که نیازی به برقراری اتصال ندارد.
این به ما این امکان را میدهد که بدون نیاز به افشای جزئیات پیچیده در برابر کد برنامه (که میخواستیم تا حد امکان در پروژه خود تغییر دهیم) از WebRTC استفاده کنیم.
حداقل WebRTC
WebRTC مجموعه ای از API های موجود در مرورگرها است که انتقال همتا به همتای صدا، تصویر و داده های دلخواه را فراهم می کند.
ارتباط بین همتایان (حتی اگر NAT در یک یا هر دو طرف وجود داشته باشد) با استفاده از سرورهای STUN و/یا TURN از طریق مکانیزمی به نام ICE برقرار می شود. همتایان اطلاعات ICE و پارامترهای کانال را از طریق پیشنهاد و پاسخ پروتکل SDP مبادله می کنند.
وای! چند علامت اختصاری در یک زمان؟ بیایید به طور خلاصه توضیح دهیم که این اصطلاحات به چه معنا هستند:
Session Traversal Utilities برای NAT (STUN) - یک پروتکل برای دور زدن NAT و به دست آوردن یک جفت (IP، پورت) برای تبادل مستقیم داده ها با میزبان. اگر او موفق به انجام وظیفه خود شود، همتایان می توانند به طور مستقل داده ها را با یکدیگر مبادله کنند.
پیمایش با استفاده از رله در اطراف NAT (دور زدن) همچنین برای پیمایش NAT استفاده می شود، اما این کار را با ارسال داده ها از طریق یک پروکسی که برای هر دو همتا قابل مشاهده است، پیاده سازی می کند. تأخیر می افزاید و اجرای آن گرانتر از STUN است (زیرا در کل جلسه ارتباط اعمال می شود)، اما گاهی اوقات تنها گزینه است.
ایجاد ارتباط تعاملی (ICE) برای انتخاب بهترین روش ممکن برای اتصال دو همتا بر اساس اطلاعات به دست آمده از اتصال مستقیم همتایان و همچنین اطلاعات دریافت شده توسط هر تعداد سرور STUN و TURN استفاده می شود.
پروتکل شرح جلسه (SDP) فرمتی برای توصیف پارامترهای کانال اتصال است، به عنوان مثال، نامزدهای ICE، کدکهای چند رسانهای (در مورد کانال صوتی/تصویری)، و غیره... یکی از همتایان یک پیشنهاد SDP ارسال میکند و دومی با یک پاسخ SDP پاسخ میدهد. . . پس از این، یک کانال ایجاد می شود.
برای ایجاد چنین ارتباطی، همتایان باید اطلاعاتی را که از سرورهای STUN و TURN دریافت می کنند جمع آوری کرده و با یکدیگر مبادله کنند.
مشکل این است که آنها هنوز توانایی برقراری ارتباط مستقیم را ندارند، بنابراین یک مکانیسم خارج از باند باید برای تبادل این داده ها وجود داشته باشد: یک سرور سیگنالینگ.
سرور سیگنالینگ می تواند بسیار ساده باشد زیرا تنها وظیفه آن ارسال داده ها بین همتایان در مرحله دست دادن است (همانطور که در نمودار زیر نشان داده شده است).
نمودار توالی دست دادن WebRTC ساده شده
نمای کلی مدل شبکه Teeworlds
معماری شبکه Teeworlds بسیار ساده است:
اجزای کلاینت و سرور دو برنامه متفاوت هستند.
کلاینت ها با اتصال به یکی از چندین سرور وارد بازی می شوند که هر کدام در هر بار فقط یک بازی را میزبانی می کنند.
تمام انتقال اطلاعات در بازی از طریق سرور انجام می شود.
یک سرور اصلی ویژه برای جمع آوری لیستی از تمام سرورهای عمومی که در کلاینت بازی نمایش داده می شوند استفاده می شود.
به لطف استفاده از WebRTC برای تبادل داده، میتوانیم مؤلفه سرور بازی را به مرورگری که مشتری در آن قرار دارد منتقل کنیم. این یک فرصت عالی به ما می دهد ...
از شر سرورها خلاص شوید
فقدان منطق سرور یک مزیت خوب دارد: ما میتوانیم کل برنامه را به عنوان محتوای ثابت در صفحات Github یا روی سختافزار خود در پشت Cloudflare مستقر کنیم، بنابراین از دانلود سریع و آپتایم بالا به صورت رایگان اطمینان حاصل میکنیم. در واقع می توانیم آنها را فراموش کنیم و اگر خوش شانس باشیم و بازی محبوب شود، دیگر نیازی به مدرن سازی زیرساخت ها نیست.
با این حال، برای اینکه سیستم کار کند، هنوز باید از یک معماری خارجی استفاده کنیم:
یک یا چند سرور STUN: ما چندین گزینه رایگان برای انتخاب داریم.
حداقل یک سرور TURN: هیچ گزینه رایگانی در اینجا وجود ندارد، بنابراین میتوانیم خودمان را راهاندازی کنیم یا هزینه سرویس را بپردازیم. خوشبختانه، در بیشتر مواقع اتصال را می توان از طریق سرورهای STUN برقرار کرد (و p2p واقعی را ارائه کرد)، اما TURN به عنوان یک گزینه بازگشتی مورد نیاز است.
سرور سیگنالینگ: برخلاف دو جنبه دیگر، سیگنالینگ استاندارد نیست. این که سرور سیگنالینگ واقعاً مسئول آن خواهد بود تا حدودی به برنامه کاربردی بستگی دارد. در مورد ما، قبل از برقراری ارتباط، لازم است مقدار کمی داده مبادله شود.
سرور اصلی Teeworlds: توسط سرورهای دیگر برای تبلیغ وجود خود و توسط مشتریان برای یافتن سرورهای عمومی استفاده می شود. در حالی که نیازی به آن نیست (مشتریان همیشه می توانند به صورت دستی به سروری که از آن اطلاع دارند متصل شوند)، داشتن آن بسیار خوب است تا بازیکنان بتوانند در بازی هایی با افراد تصادفی شرکت کنند.
ما تصمیم گرفتیم از سرورهای رایگان STUN گوگل استفاده کنیم و خودمان یک سرور TURN را مستقر کردیم.
سرور اصلی 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 های محلی و اتصالات تنبل.
ثبت پاسخ به تماس
حتی اگر برنامه اصلی از ورودی/خروجی غیرمسدود کننده استفاده کند، کد باید برای اجرا در مرورگر وب مجدداً تغییر داده شود.
دلیل این امر این است که حلقه رویداد در مرورگر از برنامه (جاوا اسکریپت یا 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() پاسخ را بدون اجرای صریح bind دریافت خواهد کرد.
در مورد ما، کلاینت به صورت خارجی کلید رشته را دریافت می کند و از تابع استفاده می کند resolve() برای بدست آوردن آدرس IP
در این مرحله، اگر دو همتا هنوز به یکدیگر متصل نباشند، یک WebRTC handshake را آغاز می کنیم. اتصالات به پورت های مختلف یک همتا از همان WebRTC DataChannel استفاده می کنند.
غیر مستقیم هم اجرا می کنیم bind()تا سرور بتواند در مرحله بعدی دوباره وصل شود sendto() در صورت بسته شدن به دلایلی
هنگامی که مشتری پیشنهاد SDP خود را در زیر اطلاعات پورت سرور در Firebase می نویسد، سرور از اتصال مشتری مطلع می شود و سرور با پاسخ خود در آنجا پاسخ می دهد.
نمودار زیر نمونه ای از جریان پیام را برای یک طرح سوکت و انتقال اولین پیام از مشتری به سرور نشان می دهد:
نمودار کامل فاز اتصال بین کلاینت و سرور
نتیجه
اگر تا اینجا خوانده باشید، احتمالاً علاقه مند هستید که این نظریه را در عمل ببینید. بازی را می توان در teeworlds.leaningtech.com، امتحانش کن
مسابقه دوستانه بین همکاران
کد کتابخانه شبکه به صورت رایگان در دسترس است گیتهاب. به گفتگو در کانال ما بپیوندید Gitter!