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

إضافة ميزة WebRTC إلى لعبة Hobbit Experience

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

  • تشغيل الألعاب بين الأجهزة باستخدام WebRTC وWebGL على متصفّح Chrome لأجهزة Android
  • أنشئ لعبة متعددة اللاعبين سهلة اللعب تعتمد على الإدخال باللمس.
  • الاستضافة على Google Cloud Platform

تحديد اللعبة

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

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

أجزاء من اللعبة

لنجعل هذه اللعبة متعدّدة اللاعبين، عليك مراعاة بعض الجوانب الأساسية:

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

إدارة اللاعبين

لاستيعاب عدد كبير من اللاعبين، نستخدم العديد من غرف الألعاب الموازية لكل ساحة معركة. السبب الرئيسي لوضع حدّ أقصى لعدد اللاعبين في كل غرفة ألعاب هو السماح للاعبين الجدد بالوصول إلى أعلى ترتيب في قائمة الصدارة خلال فترة زمنية معقولة. ويرتبط الحدّ الأقصى أيضًا بحجم ملف 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 مثالية، بل تتضمّن بعض القيود، وأبرزها حجم القيمة التي تبلغ 1 ميغابايت (لا يمكن أن تتضمّن عددًا كبيرًا جدًا من الغرف المرتبطة بساحة معركة) وانتهاء صلاحية المفتاح، أو كما توضّح المستندات:

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

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

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

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

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

إعداد WebRTC

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

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

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

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

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

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

يجب أيضًا أن يكون بإمكان عدد خوادم TURN المستخدَمة التوسّع حسب عدد الزيارات. لحلّ هذه المشكلة، اختبرنا مدير عمليات النشر من Google. ويسمح لنا هذا الإجراء بنشر الموارد ديناميكيًا على Google Compute Engine وتثبيت خوادم TURN باستخدام نموذج. لا يزال الإصدار الأولي في مرحلة الإصدار الأولي، ولكن لتحقيق أهدافنا، عمل بطريقة لا تشوبها شائبة. بالنسبة إلى خادم turn، نستخدم دالة coturn، وهي تنفيذ سريع وفعَّال وموثوق على ما يبدو لـ STUN/turn.

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

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

واجهنا بعض المشاكل عند استخدام واجهة برمجة التطبيقات Channels API. على سبيل المثال، بما أنّ الرسائل يمكن أن تأتي بدون ترتيب، كان علينا لفّ جميع الرسائل في عنصر وترتيبها. في ما يلي بعض الأمثلة على الرموز البرمجية التي توضّح آلية عمل هذه الطريقة:

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

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

محرك اللعبة

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

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

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

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

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