في الصيف الماضي، عملتُ كمسؤول فني في لعبة تجارية تستخدم WebGL، وهي SONAR. استغرق إكمال المشروع حوالي ثلاثة أشهر، وتم تنفيذه بالكامل من البداية باستخدام JavaScript. أثناء تطوير SONAR، كان علينا إيجاد حلول مبتكرة لعدد من المشاكل في بيئة HTML5 الجديدة وغير المختبَرة. على وجه الخصوص، كنّا بحاجة إلى حلّ لمشكلة تبدو بسيطة: كيف يمكننا تنزيل وتخزين أكثر من 70 ميغابايت من بيانات اللعبة مؤقتًا عندما يبدأ اللاعب اللعبة؟
تقدّم المنصات الأخرى حلولاً جاهزة لهذه المشكلة. تُحمّل معظم ألعاب وحدات التحكّم وألعاب الكمبيوتر الشخصي الموارد من قرص مضغوط/قرص 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 أو 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. أردنا استخدام مساحة تخزين دائمة لأنّه يمكن محو مساحة التخزين المؤقت في أي وقت، حتى أثناء لعب المستخدم للعبتنا وقبل أن تحاول تحميل الملف الذي تم إخراجه مباشرةً.
بالنسبة إلى التطبيقات التي تستهدف "سوق 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
);
}