دراسة حالة - 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 الرئيسي إلا بعد تحميل جميع الموارد. وما عليك سوى الانتظار إلى أن يتم استدعاء شاشة تحميل اللعبة قبل الانتقال إلى الشاشة التالية.

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

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

موارد التغليف

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

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

إن إنشاء تنسيق ملف حزمة مخصص ليس بالأمر الهين، بل قد يحل بعض المشكلات، إلا أنه يتطلب إنشاء أداة لإنشاء تنسيق الحزمة. الحل البديل هو استخدام تنسيق أرشيف حالي يتضمن أدوات حالية، ثم الحاجة إلى كتابة برنامج فك ترميز لتشغيله في المتصفح. لا نحتاج إلى تنسيق أرشيف مضغوط لأن بروتوكول HTTP يمكنه ضغط البيانات باستخدام برنامج gzip أو تقليص الخوارزميات بشكل جيد. لهذه الأسباب، استقرنا على تنسيق ملف 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

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

لا تتوفر واجهة برمجة التطبيقات FileSystem. أولاً، إنّها واجهة تعتمد على الأحداث، لأنّ هذا الإجراء يجعل واجهة برمجة التطبيقات غير محظورة، ما يجعل استخدامها أمرًا صعبًا بالنسبة إلى واجهة المستخدم. يمكن أن يخفف استخدام واجهة برمجة التطبيقات FileSystem من أحد أدوات 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
  );
}