Studi Kasus - SONAR, Pengembangan Game HTML5

Sean Middleditch
Sean Middleditch

Pengantar

Musim panas lalu saya bekerja sebagai pemimpin teknis di game WebGL komersial bernama SONAR. Proyek ini selesai dalam waktu sekitar tiga bulan, dan dikerjakan sepenuhnya dari awal di JavaScript. Selama pengembangan SONAR, kami harus menemukan solusi inovatif untuk sejumlah masalah di perairan HTML5 yang baru dan belum teruji. Secara khusus, kami memerlukan solusi untuk masalah yang tampaknya sederhana: bagaimana cara mendownload dan meng-cache 70+ MB data game saat pemain memulai game?

Platform lain memiliki solusi siap pakai untuk masalah ini. Sebagian besar konsol dan game PC memuat resource dari CD/DVD lokal atau dari hard-drive. Flash dapat mengemas semua sumber daya sebagai bagian dari file SWF yang berisi game, sedangkan Java dapat melakukan hal yang sama dengan file JAR. Platform distribusi digital seperti Steam atau App Store memastikan bahwa semua resource didownload dan diinstal sebelum pemain dapat memulai game.

HTML5 tidak memberi kita mekanisme tersebut, namun memberikan semua alat yang kita perlukan untuk membangun sistem unduhan sumber daya game sendiri. Keuntungan membangun sistem sendiri adalah kita mendapatkan semua kontrol dan fleksibilitas yang kita perlukan, dan dapat membangun sistem yang benar-benar sesuai dengan kebutuhan kita.

Pengambilan

Sebelum kita memiliki cache resource, kita sudah memiliki loader resource berantai sederhana. Sistem ini memungkinkan kita meminta resource individual melalui jalur relatif, yang pada akhirnya dapat meminta lebih banyak resource. Layar pemuatan menampilkan pengukur progres sederhana yang mengukur jumlah data yang perlu dimuat, dan bertransisi ke layar berikutnya hanya setelah antrean loader resource kosong.

Desain sistem ini memungkinkan kami dengan mudah beralih antara resource terpaket dan resource longgar (tidak dikemas) yang disalurkan melalui server HTTP lokal, yang sangat berperan penting dalam memastikan bahwa kami dapat dengan cepat melakukan iterasi pada kode dan data game.

Kode berikut mengilustrasikan desain dasar loader resource berantai kami, dengan penanganan error dan kode pemuatan XHR/gambar yang lebih canggih dihapus agar semuanya tetap dapat dibaca.

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();
};

Penggunaan antarmuka ini cukup sederhana tetapi juga cukup fleksibel. Kode game awal dapat meminta beberapa file data yang menjelaskan level game dan objek game awal. Ini mungkin berupa file JSON sederhana, misalnya. Callback yang digunakan untuk file ini kemudian memeriksa data tersebut dan dapat membuat permintaan tambahan (permintaan berantai) untuk dependensi. File definisi objek game mungkin mencantumkan model dan material, sedangkan callback untuk material mungkin akan meminta gambar tekstur.

Callback oncomplete yang dilampirkan ke instance ResourceLoader utama hanya akan dipanggil setelah semua resource dimuat. Layar pemuatan game cukup menunggu callback tersebut dipanggil sebelum beralih ke layar berikutnya.

Tentu saja, banyak hal yang bisa dilakukan dengan antarmuka ini. Sebagai latihan untuk pembaca, beberapa fitur tambahan yang perlu diselidiki adalah menambahkan dukungan progres/persentase, menambahkan pemuatan gambar (menggunakan jenis Image), menambahkan penguraian otomatis file JSON, dan tentu saja, penanganan error.

Fitur paling penting untuk artikel ini adalah kolom baseurl, yang memungkinkan kita mengganti sumber file yang diminta dengan mudah. Sangat mudah untuk menyiapkan mesin inti untuk mengizinkan jenis parameter kueri ?uselocal di URL untuk meminta resource dari URL yang disajikan oleh server Web lokal yang sama (seperti python -m SimpleHTTPServer) yang menyajikan dokumen HTML utama untuk game, saat menggunakan sistem cache jika parameter tidak disetel.

Resource Pengemasan

Satu masalah dengan pemuatan berantai resource adalah tidak ada cara untuk mendapatkan jumlah byte lengkap dari semua data. Konsekuensinya, tidak ada cara untuk membuat dialog progres yang sederhana dan dapat diandalkan untuk download. Karena kita akan mendownload semua konten dan menyimpannya dalam cache, dan ini bisa memakan waktu cukup lama untuk game yang lebih besar, memberikan dialog progres yang bagus kepada pemain sangatlah penting.

Perbaikan termudah untuk masalah ini (yang juga memberi kita beberapa keuntungan menarik lainnya) adalah dengan mengemas semua file sumber daya ke dalam satu paket, yang akan kita unduh dengan satu panggilan XHR, yang memberi kita kejadian kemajuan yang dibutuhkan untuk menampilkan bilah kemajuan yang bagus.

Membuat format file paket kustom tidaklah sulit, dan bahkan akan menyelesaikan beberapa masalah, tetapi akan memerlukan pembuatan alat untuk membuat format paket. Solusi alternatifnya adalah menggunakan format arsip yang ada dengan alat yang sudah tersedia, lalu Anda perlu menulis dekoder untuk dijalankan di browser. Kita tidak memerlukan format arsip terkompresi karena HTTP sudah dapat mengompresi data menggunakan algoritma gzip atau deflate dengan baik. Oleh karena itu, kami memilih format file TAR.

TAR adalah format yang relatif sederhana. Setiap kumpulan data (file) memiliki header 512 byte, diikuti oleh konten file yang ditambahkan menjadi 512 byte. {i>Header<i} hanya memiliki sedikit bidang yang relevan atau menarik untuk tujuan kita, terutama jenis dan nama file, yang disimpan pada posisi tetap dalam {i>header<i}.

Kolom header dalam format TAR disimpan di lokasi tetap dengan ukuran tetap di blok header. Misalnya, stempel waktu modifikasi terakhir file disimpan pada 136 byte dari awal {i>header<i}, dan panjangnya 12 byte. Semua bidang numerik dikodekan sebagai angka oktal yang disimpan dalam format ASCII. Kemudian, untuk mengurai kolom, kita mengekstrak kolom dari buffer array, dan untuk kolom numerik, kita memanggil parseInt() untuk memastikan untuk meneruskan parameter kedua guna menunjukkan basis oktal yang diinginkan.

Salah satu {i>field<i} yang paling penting adalah {i>type field<i}. Ini adalah angka oktal satu digit yang memberi tahu kita jenis file apa yang ada dalam kumpulan data. Hanya dua jenis catatan yang menarik untuk tujuan kita adalah file reguler ('0') dan direktori ('5'). Jika kita berurusan dengan file TAR arbitrer, kita mungkin juga perlu mempertimbangkan link simbolis ('2') dan mungkin link keras ('1').

Setiap header segera diikuti oleh konten file yang dijelaskan oleh header (kecuali jenis file yang tidak memiliki konten sendiri, seperti direktori). Konten file kemudian diikuti oleh padding untuk memastikan bahwa setiap header dimulai pada batas 512 byte. Dengan demikian, untuk menghitung total panjang catatan file dalam file TAR, kita harus membaca header file tersebut terlebih dahulu. Kami kemudian menambahkan panjang {i>header<i} (512 byte) dengan panjang isi file yang diekstrak dari {i>header<i}. Akhirnya, kami menambahkan byte padding yang diperlukan untuk membuat offset selaras dengan 512 byte, yang dapat dilakukan dengan mudah dengan membagi panjang file dengan 512, mengambil langit-langit angka, dan kemudian mengalikannya dengan 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
  };
};

Saya mencari pembaca TAR yang ada, dan menemukan beberapa, tetapi tidak ada yang tidak memiliki dependensi lain atau yang dengan mudah sesuai dengan codebase yang ada. Karena alasan ini, saya memilih untuk menulisnya sendiri. Saya juga meluangkan waktu untuk mengoptimalkan pemuatan sebaik mungkin, dan memastikan decoder menangani data biner dan string di dalam arsip dengan mudah.

Salah satu masalah pertama yang harus saya selesaikan adalah bagaimana cara mendapatkan data yang dimuat dari permintaan XHR. Awalnya saya memulai dengan pendekatan "string biner". Sayangnya, mengonversi dari string biner ke bentuk biner yang lebih mudah digunakan seperti ArrayBuffer tidaklah mudah, dan konversi seperti itu juga tidak terlalu cepat. Mengonversi ke objek Image juga sama menyakitkannya.

Saya memilih memuat file TAR sebagai ArrayBuffer langsung dari permintaan XHR dan menambahkan fungsi praktis kecil untuk mengonversi potongan dari ArrayBuffer menjadi string. Saat ini kode saya hanya menangani karakter ANSI/8-bit dasar, tetapi ini dapat diperbaiki setelah API konversi yang lebih praktis tersedia di browser.

Kode ini hanya memindai ArrayBuffer yang mengurai header kumpulan data, yang mencakup semua kolom header TAR yang relevan (dan beberapa yang tidak terlalu relevan) serta lokasi dan ukuran data file dalam ArrayBuffer. Kode ini juga dapat mengekstrak data secara opsional sebagai tampilan ArrayBuffer dan menyimpannya di daftar header data yang ditampilkan.

Kode ini tersedia secara gratis dengan lisensi Open Source yang ramah dan permisif di https://github.com/subsonicllc/TarReader.js.

API FileSystem

Untuk benar-benar menyimpan isi file dan mengaksesnya di lain waktu, kami menggunakan FileSystem API. API ini terbilang baru tetapi sudah memiliki beberapa dokumentasi bagus, termasuk artikel HTML5 Rocks FileSystem yang sangat bagus.

FileSystem API ada tanpa peringatan. Untuk satu hal, ini adalah antarmuka berbasis peristiwa; ini membuat API non-pemblokiran yang bagus untuk UI tetapi juga membuatnya sulit digunakan. Menggunakan FileSystem API dari WebWorker dapat mengurangi masalah ini, tetapi hal tersebut mengharuskan Anda memisahkan seluruh sistem download dan unpacking ke dalam WebWorker. Itu bahkan mungkin menjadi pendekatan terbaik, tetapi ini bukan yang saya gunakan karena keterbatasan waktu (saya belum terbiasa dengan WorkWorkers), jadi saya harus berurusan dengan API berbasis peristiwa asinkron.

Kebutuhan kita sebagian besar terfokus pada penulisan file ke dalam struktur direktori. Diperlukan serangkaian langkah untuk setiap file. Pertama, kita perlu mengambil jalur file dan mengubahnya menjadi daftar, yang dapat dilakukan dengan mudah dengan memisahkan string jalur di karakter pemisah jalur (yang selalu berupa garis miring, seperti URL). Kemudian kita perlu melakukan iterasi pada setiap elemen dalam daftar yang dihasilkan, simpan untuk yang terakhir, secara rekursif membuat direktori (jika perlu) dalam sistem file lokal. Kemudian, kita dapat membuat file, lalu membuat FileWriter, dan terakhir menulis isi file.

Hal penting kedua yang harus dipertimbangkan adalah batas ukuran file di penyimpanan PERSISTENT FileSystem API. Kami menginginkan penyimpanan persisten karena penyimpanan sementara dapat dikosongkan kapan saja, termasuk saat pengguna sedang bermain game tepat sebelum mencoba memuat file yang dikeluarkan.

Untuk aplikasi yang menargetkan Chrome Web Store, tidak ada batas penyimpanan saat menggunakan izin unlimitedStorage di file manifes aplikasi. Namun, aplikasi web biasa tetap dapat meminta ruang dengan antarmuka permintaan kuota eksperimental.

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