تجربة الهوبيت 2014

إضافة أسلوب اللعب WebRTC إلى تجربة الهوبيت

دانيال إيساكسون
دانيال إيساكسون

في وقت عرض فيلم الهوبيت الجديد "الهوبيت: معركة الجيوش الخمسة"، عملنا على تمديد تجربة Chrome للعام الماضي، رحلة عبر ميدل ايرث مع بعض المحتوى الجديد. ينصبّ التركيز الرئيسي هذه المرة على توسيع نطاق استخدام WebGL، حيث يمكن لعدد أكبر من المتصفحات والأجهزة عرض المحتوى والعمل باستخدام إمكانات WebRTC في Chrome وFirefox. كان لدينا ثلاثة أهداف من خلال تجربة هذه السنة:

  • أسلوب اللعب من نظير لنظير (P2P) باستخدام WebRTC وWebOS على Chrome لنظام Android
  • أنشِئ لعبة متعددة اللاعبين سهلة التشغيل وتعتمد على الإدخال باللمس.
  • الاستضافة على Google Cloud Platform

تحديد اللعبة

يستند منطق اللعبة إلى شبكة من التجهيزات التي تتحرك فيها القوات على لوحة ألعاب. وهذا جعلنا من السهل علينا تجربة أسلوب اللعب على الورق لأنّنا كنا نحدّد القواعد. يساعدك أيضًا استخدام إعدادات الشبكة في رصد الاصطدامات في اللعبة للحفاظ على أداء جيد، لأنّه ما عليك سوى التحقّق من الاصطدامات بالعناصر الموجودة في المربّعات نفسها أو المربّعات المجاورة. لقد عرفنا منذ البداية أننا نريد أن تركز اللعبة الجديدة على معركة بين القوى الأربع الرئيسية للأرض الوسطى، والبشر، والأقزام، والأورك، والأورك. يجب أيضًا أن يكون التطبيق عرضيًا بما يكفي لتشغيله ضمن تجربة Chrome، وألا يتضمّن الكثير من التفاعلات لتعلّمها. بدأنا بتحديد خمس ساحات معركة على خريطة ميدل ايرث لتكون بمثابة غرف ألعاب حيث يمكن للاعبين متعددين التنافس في معركة نظير إلى نظير. عرض عدة لاعبين في الغرفة على شاشة الهاتف المحمول، والسماح للمستخدمين باختيار من سيكون تحديًا في حد ذاته. لتسهيل التفاعل والمشهد، قررنا وجود زر واحد فقط للتحدي والقبول وعدم استخدام الغرفة إلا لعرض الأحداث ومن هو الملك الحالي للتلال. أدى هذا التوجيه أيضًا إلى حل بعض المشكلات من جانب مواءمة المواءمة وسمح لنا بمطابقة أفضل المرشحين للمعركة. في تجربة Chrome السابقة Cube Slam، علمنا أن التعامل مع وقت الاستجابة في لعبة متعددة اللاعبين يتطلّب جهدًا كبيرًا إذا كانت نتيجة اللعبة تعتمد على ذلك. عليك بشكل مستمر وضع افتراضات حول الوضع الذي ستظهر فيه حالة الخصم، وحيث يعتقد خصمك أنك موجود بالفعل، ومزامنة ذلك مع الرسوم المتحركة على أجهزة مختلفة. توضّح هذه المقالة هذه التحديات بمزيد من التفصيل. لجعل هذه اللعبة أكثر سهولة، صمّمنا هذه اللعبة بالاستناد إلى تناوب الأدوار.

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

أجزاء اللعبة

لإنشاء هذه اللعبة المتعددة اللاعبين، كان علينا صياغة بعض الجوانب الرئيسية:

  • واجهة برمجة التطبيقات لإدارة اللاعبين من جهة الخادم تتعامل مع المستخدمين ومباريات المطابقة والجلسات وإحصاءات الألعاب.
  • الخوادم للمساعدة في إنشاء الاتصال بين اللاعبين.
  • يشير هذا المصطلح إلى واجهة برمجة تطبيقات للتعامل مع إشارات واجهة برمجة التطبيقات لقنوات AppEngine التي تُستخدَم للاتصال بجميع اللاعبين في غرف الألعاب والتواصل معهم.
  • محرّك لعبة JavaScript يعالج مزامنة الحالة ورسائل RTC بين المشغّلين/النظيرين.
  • عرض لعبة WebGL

إدارة اللاعب

لدعم عدد كبير من اللاعبين، نستخدم العديد من غرف الألعاب المتوازية لكل ساحة Battleground. السبب الرئيسي للحد من عدد اللاعبين في كل غرفة ألعاب هو السماح للّاعبين الجدد بالوصول إلى الصدارة في وقت معقول. يرتبط هذا الحدّ أيضًا بحجم كائن json الذي يصف غرفة الألعاب المُرسَلة من خلال Channel API والذي يبلغ حدّه 32 كيلوبايت. علينا تخزين بيانات اللاعبين والغرف والنتائج والجلسات وعلاقاتهم في اللعبة. لإجراء ذلك، استخدمنا أولاً NDB للكيانات واستخدمنا واجهة طلب البحث للتعامل مع العلاقات. NDB هو واجهة لمخزن بيانات Google Cloud. كان استخدام NDB جيدًا في البداية ولكن سرعان ما واجهنا مشكلة في كيفية استخدامه. تم تنفيذ طلب البحث على الإصدار "الالتزام به" من قاعدة البيانات (يتم شرح عمليات الكتابة في NDB بالتفصيل في هذه المقالة المفصّلة)، وقد يحدث تأخير لعدة ثوانٍ. لكن الكيانات نفسها لم يكن لديها هذا التأخير عندما تستجيب مباشرة من ذاكرة التخزين المؤقت. قد يكون شرحه أسهل قليلاً باستخدام بعض نماذج التعليمات البرمجية:

// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
    room = Room.get_by_id(room_id)
    
    player = Player.get_by_id(player_id)
    player.room = room.key
    player.put()
    
    // the player Entity is updated directly in the cache
    // so calling this will return the room key as expected
    player.room // = Key(Room, room_id)

    // Fetch all the players with room set to 'room.key'
    players_in_room = Player.query(Player.room == room.key).fetch()
    // = [] (an empty list of players)
    // even though the saved player above may be expected to be in the
    // list it may not be there because the query api is being run against the 
    // "committed" version and may still be empty for a few seconds

    return {
        room: room,
        players: players_in_room,
    }

بعد إضافة اختبارات الوحدات، يمكننا أن نرى المشكلة بوضوح وابتعدنا عن طلبات البحث للاحتفاظ بالعلاقات في قائمة مفصولة بفواصل في memcache بدلاً من ذلك. بدا الأمر وكأنه عملية اختراق، ولكنها نجحت، ونجحت النماذج في AppEngine Memcache لنظام للمفاتيح باستخدام ميزة "المقارنة والتعيين" الممتازة، لذلك تم اجتياز الاختبارات مرة أخرى الآن.

لا تجمع رموز Memcache بين أقواس القزح ووحيد القرن، ولكنّها تخضع لبعض القيود، أبرزها حجم القيمة 1 ميغابايت (لا يمكن أن يتضمّن عدد كبير من الغرف المرتبطة بساحة المعركة) وتاريخ انتهاء صلاحية المفتاح، أو على النحو الموضّح في المستندات:

فكرنا في استخدام متجر آخر رائع لقيم المفاتيح، وهو Redis. ولكن في ذلك الوقت كان إعداد مجموعة قابلة للتوسع أمرًا شاقًا إلى حد ما، وبما أننا كنا نفضل التركيز على بناء التجربة بدلاً من صيانة الخوادم، فلم نسلك هذا المسار. من ناحية أخرى، أصدرت Google Cloud Platform مؤخرًا ميزة انقر للنشر بسيطة، وكان أحد الخيارات عبارة عن مجموعة Redis، ولذلك سيكون خيارًا مثيرًا للاهتمام للغاية.

وأخيرًا، عثرنا على Google Cloud SQL ونقلنا العلاقات إلى MySQL. لقد بذلت مجهودًا كبيرًا ولكن سارت الأمور بشكل رائع في نهاية المطاف، وأصبحت التحديثات دقيقة ولا تزال الاختبارات تجتاز الاختبارات. كما جعلت تنفيذ عملية صنع المطابقة وتسجيل النتائج أكثر موثوقية.

بمرور الوقت، تم نقل المزيد من البيانات ببطء من NDB وmemcache إلى SQL ولكن بشكل عام لا يزال اللاعب وكيانات ساحة المعركة والغرفة مخزنة في NDB بينما يتم تخزين الجلسات والعلاقات بينها جميعًا في SQL.

كان علينا أيضًا تتبُّع من كان يلعب، والاستعانة باللاعبين ضد بعضهما، باستخدام آلية مطابقة تأخذ في الاعتبار مستوى مهارة اللاعبين وخبرتهم. استندنا إلى عملية المطابقة من خلال المكتبة المفتوحة المصدر Glicko2.

وبما أنّ هذه اللعبة تضم لاعبين متعددين، نودّ إعلام اللاعبين الآخرين بأحداث مثل "من دخل أو غادر" و"من فاز أم خسر" وما إذا كان هناك تحدٍّ لقبوله. ولمعالجة هذه المشكلة، وفّرنا إمكانية تلقّي الإشعارات في Player Management API.

إعداد WebRTC

عندما يتم اختيار لاعبَين للمشاركة في المعركة، يتم استخدام خدمة إشارة للتواصل بين اللاعبين النظيرين معًا والمساعدة في بدء التواصل مع بعضهم بعضًا.

تتوفّر عدة مكتبات تابعة لجهات خارجية يمكنك استخدامها لخدمة الإشارة، ما يؤدي أيضًا إلى تبسيط عملية إعداد WebRTC. بعض الخيارات هي PeerJS وSimpleWebRTC وPubNub WebRTC SDK. يستخدم PubNub حل خادم مستضاف، كما أردنا في هذا المشروع استضافته على Google Cloud Platform. تستخدم المكتبتان الأخريان خوادم العقدة.js التي كان بإمكاننا تثبيتها على Google Compute Engine ولكن سيتعين علينا أيضًا التأكد من قدرتها على التعامل مع آلاف المستخدمين المتزامنين، وهو أمر كنا نعرف بالفعل أنه يمكن أن تفعله واجهة برمجة تطبيقات القناة.

تتمثل إحدى المزايا الرئيسية لاستخدام Google Cloud Platform في هذه الحالة في التوسيع. يمكن بسهولة تغيير حجم الموارد اللازمة لمشروع AppEngine من خلال Google Developers Console ولا يلزم اتخاذ أي إجراء إضافي لتوسيع نطاق خدمة الإشارة عند استخدام واجهة برمجة تطبيقات القنوات.

كانت هناك بعض المخاوف بشأن وقت الاستجابة ومدى قوة واجهة برمجة التطبيقات للقنوات، ولكننا استخدمناها سابقًا في مشروع CubeSlam وقد أثبتت فعاليتها لملايين المستخدمين في هذا المشروع ولذلك قررنا استخدامها مرة أخرى.

بما أنّنا لم نختر استخدام مكتبة تابعة لجهة خارجية للمساعدة بشأن WebRTC، كان علينا إنشاء مكتبة خاصة بنا. لحسن الحظ، يمكننا إعادة استخدام الكثير من العمل الذي قمنا به لمشروع CubeSlam. عندما ينضم كلا اللاعبين إلى جلسة معيّنة، يتم ضبط الجلسة على "نشطة"، وسيستخدم كلا اللاعبين رقم تعريف الجلسة النشط لبدء الاتصال من نظير إلى نظير من خلال واجهة برمجة تطبيقات القناة. بعد ذلك، سيتم التعامل مع كل الاتصالات بين اللاعبين من خلال RTCDataChannel.

نحتاج أيضًا إلى خوادم STUN وCurrents للمساعدة في إنشاء الاتصال والتعامل مع جدران الحماية وNAT. يمكنك الاطّلاع على مزيد من المعلومات حول إعداد WebRTC في مقالة HTML5 Rocks WebRTC في العالم الحقيقي: STUN، Turn، والإشارات.

كما يجب أن يكون عدد خوادم turn المستخدمة قادرًا على تغيير حجمها استنادًا إلى حركة المرور. ولمعالجة ذلك، اختبرنا مدير النشر في Google. ويسمح لنا ذلك بنشر الموارد ديناميكيًا على Google Compute Engine وتثبيت خوادم turn باستخدام نموذج. ولا يزال الإصدار الأولي في مرحلة الإصدار الأولي، ولكنّه يعمل بدون أي عيوب لأغراضنا. بالنسبة إلى خادم Current، نستخدم coturn، وهي عملية تنفيذ سريعة وفعّالة وموثوقة على ما يبدو.

واجهة برمجة تطبيقات القناة

يتم استخدام Channel API لإرسال جميع المراسلات من وإلى غرفة الألعاب من جهة العميل. تستخدم واجهة برمجة التطبيقات لإدارة اللاعبين واجهة برمجة التطبيقات Channel API لإشعاراتها حول أحداث الألعاب.

كان العمل مع واجهة برمجة التطبيقات للقنوات (Channels) يواجه بعض المتغيّرات في سرعته. ومن الأمثلة على ذلك أنه نظرًا لأن الرسائل يمكن أن تأتي بدون ترتيب، كان علينا لف جميع الرسائل في كائن وفرزها. إليك بعض الأمثلة عن الرموز البرمجية التي تُظهر كيفية عملها:

var que = [];  // [seq, packet...]
var seq = 0;
var rcv = -1;

function send(message) {
  var packet = JSON.stringify({
    seq: seq++,
    msg: message
  });
  channel.send(packet);
}

function recv(packet) {
  var data = JSON.parse(packet);

  if (data.seq <= rcv) {
    // ignoring message, older or already received
  } else if (data.seq > rcv + 1) {
    // message from the future. queue it up.
    que.push(data.seq, packet);
  } else {
    // message in order! update the rcv index and emit the message
    rcv = data.seq;
    emit('message', data.message);

    // and now that we have updated the `rcv` index we 
    // will check the que for any other we can send
    setTimeout(flush, 10);
  }
}

function flush() {
  for (var i=0; i<que.length; i++) {
    var seq = que[i];
    var packet = que[i+1];
    if (data.seq == rcv + 1) {
      recv(packet);
      return; // wait for next flush
    }
  }
}

أردنا أيضًا أن نبقي واجهات برمجة التطبيقات المختلفة الخاصة بالموقع الإلكتروني مقسّمة إلى وحدات منفصلة عن استضافة الموقع الإلكتروني، وبدأنا باستخدام الوحدات المضمّنة في GAE. بعد أن تمكنّا من العمل في مجال تطوير البرامج، أدركنا أنّ واجهة برمجة التطبيقات Channel API لا تعمل مع الوحدات التطبيقية على الإطلاق. وبدلاً من ذلك، انتقلنا إلى استخدام مثيلات GAE منفصلة وواجهنا مشاكل CORS التي أجبرتنا على استخدام جسر postMessage على iframe.

محرك اللعبة

وحتى يكون المحرك للألعاب ديناميكيًا قدر الإمكان، أنشأنا تطبيق الواجهة الأمامية باستخدام أسلوب entity-component-system (ECS). عندما بدأنا تطوير الإطارات الشبكية والمواصفات الوظيفية، لم يتم تعيين، لذلك كان من المفيد للغاية أن تكون قادرًا على إضافة ميزات ومنطق خلال تقدم التطوير. على سبيل المثال، استخدم النموذج الأولي الأول نظام عرض بسيطًا للوحة الرسم لعرض الكيانات في شبكة. بعد تكرارات أخرى، تمت إضافة نظام للتصادمات والآخر للّاعبين الذين يتحكّم فيهما الذكاء الاصطناعي. في منتصف المشروع، يمكننا التبديل إلى نظام العارض ثلاثي الأبعاد دون تغيير باقي الرمز. عندما كانت أجزاء الشبكة قيد التشغيل وتشغيلها، يمكن تعديل نظام الذكاء الاصطناعي (AI) لاستخدام الأوامر عن بُعد.

لذا، فإن المنطق الأساسي للاعبين المتعددين هو إرسال تكوين أمر الحركة إلى النظير الآخر من خلال DataChannels والسماح للمحاكاة بالعمل كما لو كانت لعبة تعتمد على الذكاء الاصطناعي. بالإضافة إلى ذلك، هناك منطق لتحديد مساره، إذا ضغط اللاعب على أزرار التمرير/الهجوم، أو أوامر "قائمة المحتوى التالي" عند تلقّيها بينما لا يزال اللاعب يشاهد الصورة المتحركة السابقة وما إلى ذلك.

إذا كان هناك مستخدمان يبدّلان الأدوار فقط، يمكن لكلا الطرفين تقاسم المسئولية تمرير الدور إلى الخصم عند الانتهاء، ولكن هناك لاعب ثالث مشارك. وقد أصبح نظام الذكاء الاصطناعي متاحًا مرة أخرى (وليس للاختبار فقط)، عندما احتجنا إلى إضافة أعداء مثل العناكب والترول. ولجعلها مناسبة للتدفق القائم على تبادل، يجب أن تنشأ وتنفيذها بنفس الطريقة تمامًا على كلا الجانبين. تم حل هذه المشكلة من خلال السماح لأحد الزملاء بالتحكّم في نظام الدوران وإرسال الحالة الحالية إلى الزميل البعيد. وعندما يحين دور العناكب، يسمح مدير الدوران لنظام الذكاء الاصطناعي (AI) بإنشاء أمر يتم إرساله إلى المستخدم البعيد. بما أنّ محرّك اللعبة يعتمد فقط على الأوامر ومعرِّفات الكيانات، ستتم محاكاة اللعبة على كلا الجانبين. يمكن أن تحتوي جميع الوحدات أيضًا على مكوّن الذكاء الاصطناعي (AI) الذي يتيح اختبارًا مبرمَجًا سهلاً.

كان من الأفضل في بداية تطوير اللعبة أن يكون لديك واجهة عرض أبسط للّوحة مع التركيز على منطق اللعبة. ومع ذلك، بدأت المتعة الحقيقية عند تطبيق الإصدار الثلاثي الأبعاد وإضفاء الحيوية على المشاهد من خلال البيئات والرسوم المتحركة. نستخدم three.js كمحرك ثلاثي الأبعاد، وكان من السهل الوصول إلى حالة تشغيل إصدار منه بسبب هيكله.

يتم إرسال موضع الماوس بشكل متكرر إلى المستخدم البعيد، وتظهر تلميحات بسيطة ثلاثية الأبعاد حول مكان المؤشر في الوقت الحالي.