نحن ننقل لعبة متعددة اللاعبين من C ++ إلى الويب باستخدام Cheerp و WebRTC و Firebase

مقدمة


توفر شركتنا Leaning Technologies حلولًا لنقل تطبيقات سطح المكتب التقليدية إلى الويب. يقوم برنامج التحويل البرمجي C ++ Cheerp الخاص بنا بإنشاء توليفة من WebAssembly و JavaScript ، مما يوفر تفاعلًا سهلًا بين المتصفح والأداء العالي.

كمثال لتطبيقها ، قررنا أن ننفذ لعبة متعددة اللاعبين على الويب واخترنا Teeworlds لهذا الغرض. Teeworlds هي لعبة متعددة اللاعبين ، ثنائية الأبعاد الرجعية مع مجتمع صغير ولكن نشط من اللاعبين (بما في ذلك أنا!). إنه صغير من حيث الموارد القابلة للتنزيل ومتطلبات وحدة المعالجة المركزية ووحدة معالجة الرسومات - مرشح مثالي.


يعمل في متصفح Teeworlds

قررنا استخدام هذا المشروع لتجربة حلول عامة لنقل رمز الشبكة إلى الويب . يتم ذلك عادة بالطرق التالية:

  • XMLHttpRequest / جلب إذا كان جزء الشبكة يتكون فقط من طلبات HTTP ، أو
  • مآخذ الويب.

يتطلب كلا الحلين استضافة مكون الخادم من جانب الخادم ، ولا يسمح أي منهما باستخدام UDP كبروتوكول نقل. هذا مهم للتطبيقات في الوقت الفعلي مثل مؤتمرات الفيديو وبرامج الألعاب ، لأن ضمانات التسليم وترتيب حزم TCP يمكن أن تتداخل مع الكمون المنخفض.

هناك طريقة ثالثة - استخدام الشبكة من مستعرض: WebRTC .

يدعم RTCDataChannel كلاً من النقل الموثوق والموثوق به (في الحالة الأخيرة ، إذا كان ذلك ممكنًا ، يحاول استخدام UDP كبروتوكول نقل) ، ويمكن استخدامه مع خادم بعيد وبين المتصفحات. هذا يعني أنه يمكننا نقل التطبيق بأكمله إلى المتصفح ، بما في ذلك مكون الخادم!

ومع ذلك ، فهذه هي صعوبة إضافية: قبل أن يتمكن زميلان من WebRTC من تبادل البيانات ، يتعين عليهما إجراء إجراء مصافحة معقدة نسبيًا للاتصال ، الأمر الذي يتطلب عدة كيانات تابعة لجهات خارجية (خادم إشارة وخادم STUN / TURN أو أكثر).

من الناحية المثالية ، نود إنشاء واجهة برمجة تطبيقات للشبكة داخليًا باستخدام WebRTC ، ولكن أقرب ما يمكن من واجهة UDP Sockets ، والتي لا تحتاج إلى إنشاء اتصال.

سيتيح لنا ذلك الاستفادة من WebRTC دون الحاجة إلى الكشف عن التفاصيل المعقدة لرمز التطبيق (الذي أردنا تغييره بأقل قدر ممكن في مشروعنا).

الحد الأدنى WebRTC


WebRTC عبارة عن مجموعة API متوفرة في المتصفحات التي توفر نقل الصوت والفيديو ، والبيانات التعسفية للنظير إلى نظير.

تم تأسيس الاتصال بين الأقران (حتى لو كان هناك NAT على أحد الجانبين أو كليهما) باستخدام خوادم STUN و / أو TURN من خلال آلية تسمى ICE. يتبادل الأقران معلومات ICE ومعلمات القناة من خلال بروتوكول العرض والإجابة في SDP.

نجاح باهر! كم عدد الاختصارات في وقت واحد. لنشرح بإيجاز ما تعنيه هذه المفاهيم:

  • Utility Traversal Utilities لـ NAT ( STUN ) - بروتوكول لتجاوز NAT واستقبال زوج (IP ، منفذ) لتبادل البيانات مباشرة مع المضيف. إذا تمكن من إكمال مهمته ، يمكن للأقران تبادل البيانات بشكل مستقل مع بعضهم البعض.
  • يستخدم Traversal Using Relays around NAT ( TURN ) أيضًا لتجاوز NAT ، ولكنه يقوم بذلك عن طريق إعادة توجيه البيانات عبر وكيل مرئي لكلا الزملاء. يضيف تأخيرًا وهو أكثر تكلفة من STUN (لأنه يستخدم طوال جلسة الاتصال) ، لكن في بعض الأحيان يكون هذا هو الخيار الوحيد الممكن.
  • تُستخدم مؤسسة الاتصال التفاعلي ( ICE ) لتحديد أفضل طريقة ممكنة لتوصيل اثنين من الأقران بناءً على المعلومات التي تم الحصول عليها من خلال الاتصال المباشر بين أقرانهم ، وكذلك المعلومات التي يتلقاها أي عدد من خوادم STUN و TURN.
  • بروتوكول وصف الجلسة ( SDP ) عبارة عن تنسيق لوصف معلمات قناة الاتصال ، على سبيل المثال ، مرشحي ICE وبرامج ترميز الوسائط المتعددة (في حالة قناة صوت / فيديو) ، إلخ ... يرسل أحد أقرانه عرض SDP ("العرض") ، ويستجيب الثاني مع SDP الإجابة ("الرد"). بعد ذلك ، يتم إنشاء قناة.

لإنشاء مثل هذا الاتصال ، يحتاج الأقران إلى جمع المعلومات التي تلقوها من خوادم STUN و TURN وتبادلها مع بعضهم البعض.

المشكلة هي أنه ليس لديهم القدرة حتى الآن على تبادل البيانات مباشرة ، لذلك يجب أن يكون هناك آلية خارج النطاق لتبادل هذه البيانات: خادم الإشارة.

يمكن أن يكون خادم الإشارة بسيطًا للغاية ، لأن مهمته الوحيدة هي إعادة توجيه البيانات بين أقرانه في مرحلة "المصافحة" (كما هو موضح في الرسم التوضيحي أدناه).


WebRTC تسلسل المصافحة المبسط

نظرة عامة على نموذج شبكة Teeworlds


بنية شبكة Teeworlds بسيطة للغاية:

  • مكونات العميل والخادم هما برنامجان مختلفان.
  • يدخل العملاء اللعبة عن طريق الاتصال بأحد الخوادم المتعددة ، ويستضيف كل منها لعبة واحدة فقط في كل مرة.
  • تتم جميع عمليات نقل البيانات في اللعبة عبر الخادم.
  • يتم استخدام خادم رئيسي خاص لجمع قائمة بجميع الخوادم العامة التي يتم عرضها في عميل اللعبة.

نظرًا لاستخدام WebRTC لتبادل البيانات ، يمكننا نقل مكون خادم اللعبة إلى المتصفح الذي يوجد به العميل. إنه يعطينا فرصة عظيمة ...

تخلص من الخوادم


يتمتع الافتقار إلى منطق الخادم بميزة لطيفة: يمكننا نشر التطبيق بالكامل كمحتوى ثابت على صفحات Github أو على أجهزتنا الخاصة خلف Cloudflare ، وبالتالي ضمان التنزيلات السريعة ووقت التشغيل العالي مجانًا. في الواقع ، يمكننا نسيانهم ، وإذا كنا محظوظين وأصبحت اللعبة مشهورة ، فلن تحتاج إلى تحديث البنية التحتية.

ومع ذلك ، لكي يعمل النظام ، لا يزال يتعين علينا استخدام بنية خارجية:

  • واحد أو أكثر من خوادم STUN: لدينا خيار من بين عدة خيارات مجانية.
  • خادم TURN واحد على الأقل: لا توجد خيارات مجانية هنا ، لذلك يمكننا إما إعداد الخدمة الخاصة بنا أو الدفع مقابل الخدمة. لحسن الحظ ، في معظم الوقت يمكن تأسيس الاتصال من خلال خوادم STUN (وتوفير p2p حقيقي) ، ولكن هناك حاجة إلى TURN كاحتياطي.
  • خادم الإشارة: على عكس الجوانب الأخرى ، فإن الإشارة غير موحدة. ما خادم الإشارة سيكون مسؤولاً في الواقع يعتمد على التطبيق بطريقة ما. في حالتنا ، قبل إنشاء اتصال ، من الضروري تبادل كمية صغيرة من البيانات.
  • الخادم الرئيسي لـ Teeworlds: يتم استخدامه بواسطة خوادم أخرى للإبلاغ عن وجوده والعملاء للبحث عن خوادم عامة. على الرغم من أنه غير مطلوب (يمكن للعملاء الاتصال دائمًا بخادم يعرفونه يدويًا) ، إلا أنه سيكون من الجيد الحصول عليه حتى يتمكن اللاعبون من المشاركة في الألعاب مع أشخاص عشوائيين.

قررنا استخدام خوادم STUN المجانية من Google ، ونشرنا خادم TURN واحدًا بمفردنا.

لآخر نقطتين استخدمنا Firebase :

  • يتم تطبيق خادم Teeworlds الرئيسي بكل بساطة: كقائمة من الكائنات التي تحتوي على معلومات (الاسم ، IP ، الخريطة ، الوضع ، ...) لكل خادم نشط. تقوم الخوادم بنشر وتحديث الكائن الخاص بها ، ويأخذ العملاء القائمة بالكامل ويعرضونها على المشغل. نعرض أيضًا القائمة على الصفحة الرئيسية بتنسيق HTML ، بحيث يمكن للاعبين ببساطة النقر على الخادم والانتقال مباشرة إلى اللعبة.
  • ترتبط الإشارة ارتباطًا وثيقًا بتنفيذ مأخذ التوصيل لدينا ، الموضح في القسم التالي.


قائمة الخوادم داخل اللعبة وعلى الصفحة الرئيسية

تنفيذ المقبس


نريد إنشاء واجهة برمجة التطبيقات (API) في أقرب وقت ممكن من Posix UDP Sockets لتقليل عدد التغييرات المطلوبة.

نحن نريد أيضًا أن ندرك الحد الأدنى الضروري لأبسط تبادل للبيانات عبر الشبكة.

على سبيل المثال ، لا نحتاج إلى توجيه حقيقي: جميع أقرانهم في نفس "الشبكة المحلية الظاهرية" المرتبطة بمثيل معين من قاعدة بيانات Firebase.

لذلك ، لا نحتاج إلى عناوين IP فريدة: من أجل التحديد الفريد للأقران ، يكفي استخدام القيم الفريدة لمفاتيح Firebase (على غرار أسماء النطاقات) ، ويقوم كل نظير محليًا بتعيين عناوين IP "مزيفة" لكل مفتاح يلزم تحويله. هذا يلغي تماما الحاجة إلى تعيين عنوان IP عالمي ، وهي مهمة غير تافهة.

فيما يلي الحد الأدنى من واجهة برمجة التطبيقات التي نحتاج إلى تنفيذها:

// 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 ، ولكن لديها عدة اختلافات مهمة: تسجيل عمليات الاسترجاعات وتعيين عناوين 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 للاستخدام الداخلي. يحصل باقي واجهة برمجة التطبيقات أيضًا على هذه القيمة بدلاً من سلسلة للبساطة.

يشبه هذا بحث DNS ، يتم تنفيذه محليًا على العميل فقط.

أي أنه لا يمكن مشاركة عناوين IP بين عملاء مختلفين ، وإذا كنت بحاجة إلى نوع من المعرِّف العام ، فسيتعين عليك إنشاءه بطريقة مختلفة.

مزيج كسول


لا يحتاج UDP إلى اتصال ، ولكن ، كما رأينا ، قبل بدء نقل البيانات بين زميلين ، يتطلب WebRTC عملية اتصال طويلة.

إذا أردنا تقديم نفس المستوى من التجريد ، ( sendto / recvfrom مع أقرانهم التعسفي دون الاتصال أولاً) ، فعلينا إجراء اتصال "كسول" (مؤجل) داخل API.

إليك ما يحدث أثناء تبادل البيانات العادي بين "الخادم" و "العميل" في حالة استخدام UDP ، وما ينبغي لمكتبتنا القيام به:

  • يستدعي الخادم bind() لإعلام نظام التشغيل بأنه يريد استلام الحزم إلى المنفذ المحدد.

بدلاً من ذلك ، سنقوم بنشر المنفذ المفتوح في Firebase تحت مفتاح الخادم والاستماع إلى الأحداث في الشجرة الفرعية.

  • يستدعي الخادم recvfrom() ، ويقبل الحزم من أي مضيف إلى هذا المنفذ.

في حالتنا ، نحن بحاجة إلى التحقق من قائمة الانتظار الواردة من الحزم المرسلة إلى هذا المنفذ.

يحتوي كل منفذ على قائمة انتظار خاصة به ، ونضيف منافذ المصدر والوجهة في بداية مخططات بيانات WebRTC لمعرفة قائمة الانتظار لإعادة التوجيه عند وصول حزمة جديدة.

المكالمة غير قابلة للحظر ، لذلك إذا لم تكن هناك حزم ، فسنقوم ببساطة بإرجاع -1 وتعيين errno=EWOULDBLOCK .

  • يتلقى العميل ، من خلال بعض الوسائل الخارجية ، IP ومنفذ الخادم ، ويدعو sendto() . أيضًا ، يتم إجراء مكالمة داخلية bind() ، لذلك ستتلقى recvfrom() اللاحقة استجابة دون تنفيذ الربط بشكل صريح.

في حالتنا ، يتلقى العميل مفتاح السلسلة خارجيًا ويستخدم الدالة resolve() للحصول على عنوان IP.

في هذه المرحلة ، نبدأ "مصافحة" WebRTC إذا لم يكن الزميلان متصلان ببعضهما البعض. تستخدم الاتصالات بمنافذ مختلفة من نفس النظير نفس DataRannel WebRTC.

نقوم أيضًا بإجراء bind() غير مباشر bind() حتى يتمكن الخادم من إعادة الاتصال في sendto() التالي في حالة sendto() لسبب ما.

يتم إخطار الخادم بالاتصال بالعميل عندما يكتب العميل عرض SDP الخاص به بموجب معلومات منفذ الخادم في Firebase ، ويستجيب الخادم باستجابته الخاصة.



يوضح الرسم البياني أدناه مثالًا على حركة الرسائل لنظام المقبس ونقل الرسالة الأولى من العميل إلى الخادم:


إكمال مخطط خطوة الاتصال بين العميل والخادم

استنتاج


إذا كنت قد قرأت حتى النهاية ، فمن المحتمل أن تكون مهتمًا بالنظر إلى النظرية موضع التنفيذ. يمكن لعب اللعبة على teeworlds.leaningtech.com ، جربها!


مباراة ودية بين الزملاء

رمز مكتبة الشبكة متاح مجانًا على جيثب . انضم إلى الدردشة على قناتنا في Gitter !

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


All Articles