دراسة حالة: SONAR، تطوير ألعاب HTML5

مقدمة

في الصيف الماضي، عملت كقائد فني في لعبة WebGL تجارية باسم SONAR. استغرق المشروع حوالي ثلاثة أشهر لإكماله، وتم إنشاؤه بالكامل من البداية باستخدام JavaScript. أثناء تطوير أداة SONAR، كان علينا إيجاد حلول مبتكرة لعدد من المشاكل في لغة HTML5 الجديدة وغير المختبَرة. على وجه الخصوص، احتجنا إلى حلّ لمشكلة بسيطة ظاهريًا: كيف يمكننا تنزيل أكثر من 70 ميغابايت من بيانات اللعبة وتخزينها مؤقتًا عندما يبدأ اللاعب اللعبة؟

تتوفّر حلول جاهزة لهذه المشكلة على المنصات الأخرى. تحمِّل معظم ألعاب وحدات التحكّم والألعاب المخصّصة للكمبيوتر الشخصي الموارد من قرص CD/DVD محلي أو من محرك أقراص ثابت. يمكن لفئة Flash تجميع جميع الموارد كجزء من ملف SWF الذي يحتوي على اللعبة، ويمكن لفئة Java إجراء ذلك نفسه مع ملفات JAR. تضمن منصات التوزيع الرقمي، مثل Steam أو App Store، تنزيل جميع الموارد وتثبيتها قبل أن يتمكّن اللاعب من تشغيل اللعبة.

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

الاسترداد

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

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

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

function ResourceLoader() {
  this.pending = 0;
  this.baseurl = './';
  this.oncomplete = function() {};
}

ResourceLoader.prototype.request = function(path, callback) {
  var xhr = new XmlHttpRequest();
  xhr.open('GET', this.baseurl + path);
  var self = this;

  xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
      callback(path, xhr.response, self);

      if (--self.pending == 0) {
        self.oncomplete();
      }
    }
  };

  xhr.send();
};

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

لن يتمّ استدعاء دالة ردّ الاتصال oncomplete المرتبطة بالمثيل الرئيسي ResourceLoader إلّا بعد تحميل جميع الموارد. يمكن أن تنتظر شاشة تحميل اللعبة إلى أن يتم استدعاء هذا المرجع قبل الانتقال إلى الشاشة التالية.

يمكن تنفيذ الكثير من الإجراءات باستخدام هذه الواجهة، بالطبع. كتدريبات للقارئ، هناك بعض الميزات الإضافية التي تستحق التحقيق فيها، مثل إضافة ميزة التقدّم/النسبة المئوية، وإضافة تحميل الصور (باستخدام نوع Image)، وإضافة التحليل التلقائي لملفات JSON، وبالطبع معالجة الأخطاء.

أهم ميزة في هذه المقالة هي حقل baseurl الذي يتيح لنا تبديل مصدر الملفات التي نطلبها بسهولة. من السهل إعداد المحرّك الأساسي للسماح بنوع ?uselocal من مَعلمات طلب البحث في عنوان URL لطلب موارد من عنوان URL يعرضه خادم الويب المحلي نفسه (مثل python -m SimpleHTTPServer) الذي عرض مستند HTML الرئيسي للعبة، مع استخدام نظام ذاكرة التخزين المؤقت في حال عدم ضبط المَعلمة.

موارد التعبئة

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

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

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

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

يتم تخزين حقول الرأس بتنسيق TAR في مواضع ثابتة وبأحجام ثابتة في قالب الرأس. على سبيل المثال، يتم تخزين الطابع الزمني لآخر تعديل على الملف على بعد 136 بايت من بداية الرأس، ويبلغ طوله 12 بايت. يتم ترميز جميع الحقول الرقمية كأرقام أوكتال يتم تخزينها بتنسيق ASCII. لتحليل الحقول، نُخرج الحقول من مخزن مصفوفة، وبالنسبة إلى الحقول الرقمية، نُطلِق parseInt() مع التأكّد من تمرير المَعلمة الثانية للإشارة إلى قاعدة الأعداد الثنائية العشرية المطلوبة.

من أهم الحقول هو حقل النوع. هذا رقم ثماني بتات يشير إلى نوع الملف الذي يحتوي عليه السجلّ. نوعا السجلّ الوحيدان المثيران للاهتمام لأغراضنا هما الملفات العادية ('0') والأدلة ('5'). إذا كنا نتعامل مع ملفات TAR عشوائية، قد نهتم أيضًا بالروابط الرمزية ('2') وربما الروابط الثابتة ('1').

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

// Read a string out of an array buffer with a maximum string length of 'len'.
// state is an object containing two fields: the array buffer in 'buffer' and
// the current input index in 'index'.
function readString (state, len) {
  var str = '';

  // We read out the characters one by one from the array buffer view.
  // this actually is a lot faster than it looks, at least on Chrome.
  for (var i = state.index, e = state.index + len; i != e; ++i) {
    var c = state.buffer[i];

    if (c == 0) { // at NUL byte, there's no more string
      break;
    }

    str += String.fromCharCode(c);
  }

  state.index += len;

  return str;
}

// Read the next file header out of a tar file stored in an array buffer.
// state is an object containing two fields: the array buffer in 'buffer' and
// the current input index in 'index'.
function readTarHeader (state) {
  // The offset of the file this header describes is always 512 bytes from
  // the start of the header
  var offset = state.index + 512;

  // The header is made up of several fields at fixed offsets within the
  // 512 byte block allocated for the header.  fields have a fixed length.
  // all numeric fields are stored as octal numbers encoded as ASCII
  // strings.
  var name = readString(state, 100);
  var mode = parseInt(readString(state, 8), 8);
  var uid = parseInt(readString(state, 8), 8);
  var gid = parseInt(readString(state, 8), 8);
  var size = parseInt(readString(state, 12), 8);
  var modified = parseInt(readString(state, 12), 8);
  var crc = parseInt(readString(state, 8), 8);
  var type = parseInt(readString(state, 1), 8);
  var link = readString(state, 100);

  // The header is followed by the file contents, then followed
  // by padding to ensure that the next header is on a 512-byte
  // boundary.  advanced the input state index to the next
  // header.
  state.index = offset + Math.ceil(size / 512) * 512;

  // Return the descriptor with the relevant fields we care about
  return {
    name : name,
    size : size,
    type : type,
    offset : offset
  };
};

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

كانت إحدى المشاكل الأولى التي كان عليّ حلّها هي كيفية تحميل البيانات من طلب XHR. لقد بدأتُ في الأصل باستخدام نهج "سلسلة ثنائية". إنّ التحويل من سلاسل ثنائية إلى أشكال ثنائية أكثر سهولة في الاستخدام، مثل ArrayBuffer، ليس أمرًا مباشرًا، ولا تكون عمليات التحويل هذه سريعة بشكل خاص. إنّ عملية التحويل إلى عناصر Image ليست سهلة أيضًا.

قرّرت تحميل ملفات TAR كملف ArrayBuffer مباشرةً من طلب XHR وإضافة دالة صغيرة لتسهيل تحويل الأجزاء من ArrayBuffer إلى سلسلة. لا يتعامل الرمز البرمجي حاليًا إلا مع الأحرف الأساسية بترميز ANSI/8 بت، ولكن يمكن إصلاح ذلك بعد توفّر واجهة برمجة تطبيقات أكثر ملاءمةً للتحويل في المتصفّحات.

تفحص الرمز البرمجي ArrayBuffer ببساطة لتحليل رؤوس السجلّات، والتي تتضمّن جميع حقول رؤوس TAR ذات الصلة (وبعض الحقول غير ذات الصلة) بالإضافة إلى الموقع الجغرافي لبيانات الملف وحجمها ضمن ArrayBuffer. يمكن للرمز أيضًا استخراج البيانات كعرض ArrayBuffer اختياريًا وتخزينها في قائمة رؤوس السجلّ التي تم عرضها.

تتوفّر التعليمات البرمجية مجانًا بموجب ترخيص مفتوح المصدر وسهل الاستخدام على https://github.com/subsonicllc/TarReader.js.

FileSystem API

لتخزين محتوى الملفات والوصول إليه لاحقًا، استخدمنا واجهة برمجة التطبيقات FileSystem API. واجهة برمجة التطبيقات جديدة تمامًا، ولكنّها تتضمّن بعض المستندات الرائعة، بما في ذلك مقالة HTML5 Rocks FileSystem الرائعة.

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

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

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

بالنسبة إلى التطبيقات التي تستهدف "سوق Chrome الإلكتروني"، لا تُفرض أي حدود على مساحة التخزين عند استخدام الإذن unlimitedStorage في ملف بيان التطبيق. ومع ذلك، لا يزال بإمكان تطبيقات الويب العادية طلب مساحة باستخدام واجهة طلب الحصة التجريبية.

function allocateStorage(space_in_bytes, success, error) {
  webkitStorageInfo.requestQuota(
    webkitStorageInfo.PERSISTENT,
    space_in_bytes,
    function() {
      webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);      
    },
    error
  );
}