مطالعه موردی - SONAR، توسعه بازی HTML5

شان میدلدیچ
شان میدلدیچ

معرفی

تابستان گذشته من به عنوان سرپرست فنی روی یک بازی تجاری WebGL به نام SONAR کار کردم. تکمیل این پروژه حدود سه ماه طول کشید و به طور کامل از ابتدا در جاوا اسکریپت انجام شد. در طول توسعه SONAR، ما مجبور بودیم راه حل های نوآورانه ای برای تعدادی از مشکلات در آب های جدید و آزمایش نشده HTML5 پیدا کنیم. به طور خاص، ما به یک راه‌حل برای یک مشکل به ظاهر ساده نیاز داشتیم: چگونه می‌توانیم بیش از 70 مگابایت از داده‌های بازی را وقتی که بازیکن بازی را شروع می‌کند دانلود و کش کنیم؟

سایر پلتفرم ها راه حل های آماده ای برای این مشکل دارند. اکثر کنسول ها و بازی های رایانه شخصی منابع را از یک CD/DVD محلی یا از یک هارد دیسک بارگیری می کنند. فلش می تواند تمام منابع را به عنوان بخشی از فایل SWF که حاوی بازی است بسته بندی کند و جاوا می تواند همین کار را با فایل های JAR انجام دهد. پلتفرم‌های توزیع دیجیتال مانند استیم یا اپ استور اطمینان می‌دهند که تمامی منابع قبل از شروع بازی توسط بازیکن دانلود و نصب می‌شوند.

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' ) اهمیت دهیم.

هر سرصفحه بلافاصله با محتویات فایل توصیف شده توسط هدر دنبال می شود (به جز انواع فایل هایی که محتوای خاص خود را ندارند، مانند دایرکتوری ها). سپس محتویات فایل با padding دنبال می شوند تا اطمینان حاصل شود که هر سرصفحه روی یک مرز 512 بایتی شروع می شود. بنابراین، برای محاسبه طول کل رکورد یک فایل در یک فایل TAR، ابتدا باید هدر فایل را بخوانیم. سپس طول هدر (512 بایت) را با طول محتویات فایل استخراج شده از هدر اضافه می کنیم. در نهایت، هر بایت padding را که برای تراز کردن افست به 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 بیتی را کنترل می‌کند، اما زمانی که یک API تبدیل راحت‌تر در مرورگرها در دسترس باشد، می‌توان آن را برطرف کرد.

کد به سادگی روی ArrayBuffer سرصفحه های رکورد را تجزیه می کند، که شامل تمام فیلدهای هدر TAR مربوطه (و چند مورد نه چندان مرتبط) و همچنین مکان و اندازه داده های فایل در ArrayBuffer است. این کد همچنین می تواند به صورت اختیاری داده ها را به عنوان نمای ArrayBuffer استخراج کرده و در لیست سرصفحه رکوردهای برگشتی ذخیره کند.

این کد به‌طور رایگان تحت یک مجوز منبع باز دوستانه و آسان در https://github.com/subsonicllc/TarReader.js در دسترس است.

FileSystem API

برای ذخیره واقعی محتویات فایل و دسترسی به آنها بعدا، از FileSystem API استفاده کردیم. API کاملاً جدید است اما در حال حاضر دارای اسناد عالی است، از جمله مقاله عالی HTML5 Rocks FileSystem .

FileSystem API خالی از اخطار نیست. برای یک چیز، این یک رابط رویداد محور است. این هم باعث می‌شود API مسدود نشود که برای رابط کاربری عالی است، اما استفاده از آن را نیز سخت می‌کند. استفاده از FileSystem API از WebWorker می‌تواند این مشکل را کاهش دهد، اما این نیاز به تقسیم کل سیستم دانلود و باز کردن بسته‌بندی به WebWorker دارد. این حتی ممکن است بهترین رویکرد باشد، اما به دلیل محدودیت‌های زمانی، این روشی نیست که من با آن کار کردم (هنوز با WorkWorkers آشنا نبودم)، بنابراین مجبور شدم با ماهیت رویداد محور ناهمزمان API مقابله کنم.

نیازهای ما بیشتر بر روی نوشتن فایل ها در یک ساختار دایرکتوری متمرکز است. این به یک سری مراحل برای هر فایل نیاز دارد. ابتدا باید مسیر فایل را انتخاب کنیم و آن را به یک لیست تبدیل کنیم که به راحتی با تقسیم رشته مسیر بر روی کاراکتر جداکننده مسیر (که همیشه مانند 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
  );
}