Studi Kasus - SONAR, Pengembangan Game HTML5

Sean Middleditch
Sean Middleditch

Pengantar

Musim panas lalu, saya bekerja sebagai pimpinan teknis pada game WebGL komersial bernama SONAR. Proyek ini memerlukan waktu sekitar tiga bulan untuk diselesaikan, dan dilakukan sepenuhnya dari awal dalam JavaScript. Selama pengembangan SONAR, kami harus menemukan solusi inovatif untuk sejumlah masalah di HTML5 yang baru dan belum diuji. Secara khusus, kami memerlukan solusi untuk masalah yang tampaknya sederhana: bagaimana cara mendownload dan menyimpan dalam cache data game berukuran lebih dari 70 MB saat pemain memulai game?

Platform lain memiliki solusi siap pakai untuk masalah ini. Sebagian besar game konsol dan PC memuat resource dari CD/DVD lokal atau dari hard drive. Flash dapat memaketkan semua resource sebagai bagian dari file SWF yang berisi game, dan 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 ini, tetapi memberi kita semua alat yang diperlukan untuk membuat sistem download resource game kita sendiri. Keuntungan membangun sistem sendiri adalah kita mendapatkan semua kontrol dan fleksibilitas yang diperlukan, serta dapat membangun sistem yang sama persis dengan kebutuhan kita.

Retrieval

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

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

Kode berikut mengilustrasikan desain dasar loader resource berantai, dengan penanganan error dan kode pemuatan XHR/gambar yang lebih canggih dihapus agar 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 mendeskripsikan level game awal dan objek game. Misalnya, file ini mungkin berupa file JSON sederhana. Callback yang digunakan untuk file ini kemudian akan memeriksa data tersebut dan dapat membuat permintaan tambahan (permintaan berantai) untuk dependensi. File definisi objek game mungkin mencantumkan model dan materi, dan callback untuk materi kemudian dapat meminta gambar tekstur.

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

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

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

Referensi Pengemasan

Salah satu masalah dengan pemuatan resource berantai adalah tidak ada cara untuk mendapatkan jumlah byte lengkap dari semua data. Akibatnya, tidak ada cara untuk membuat dialog progres yang sederhana dan andal untuk download. Karena kita akan mendownload semua konten dan menyimpannya dalam cache, dan proses ini dapat memerlukan waktu yang cukup lama untuk game yang lebih besar, memberikan dialog progres yang bagus kepada pemain adalah hal yang cukup penting.

Perbaikan termudah untuk masalah ini (yang juga memberi kita beberapa keuntungan bagus lainnya) adalah dengan memaketkan semua file resource ke dalam satu paket, yang akan kita download dengan satu panggilan XHR, yang memberi kita peristiwa progres yang diperlukan untuk menampilkan status progres yang bagus.

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

TAR adalah format yang relatif sederhana. Setiap data (file) memiliki header 512 byte, diikuti dengan konten file yang ditambahkan hingga 512 byte. Header hanya memiliki beberapa kolom yang relevan atau menarik untuk tujuan kita, terutama jenis dan nama file, yang disimpan di posisi tetap dalam header.

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 header, dan panjangnya 12 byte. Semua kolom numerik dienkode sebagai angka oktal yang disimpan dalam format ASCII. Untuk mengurai kolom, kita mengekstrak kolom dari buffer array, dan untuk kolom numerik, kita memanggil parseInt() dengan memastikan untuk meneruskan parameter kedua untuk menunjukkan basis oktal yang diinginkan.

Salah satu kolom yang paling penting adalah kolom jenis. Ini adalah angka oktal satu digit yang memberi tahu kita jenis file yang dimuat dalam catatan. Hanya ada dua jenis data yang menarik untuk tujuan kita, yaitu file reguler ('0') dan direktori ('5'). Jika kita menangani file TAR arbitrer, kita mungkin juga perlu memperhatikan link simbolis ('2') dan mungkin hard link ('1').

Setiap header segera diikuti dengan konten file yang dijelaskan oleh header (kecuali jenis file yang tidak memiliki kontennya sendiri, seperti direktori). Isi file kemudian diikuti dengan padding untuk memastikan bahwa setiap header dimulai pada batas 512 byte. Jadi, untuk menghitung total panjang data file dalam file TAR, kita harus membaca header file terlebih dahulu. Kemudian, kita menambahkan panjang header (512 byte) dengan panjang konten file yang diekstrak dari header. Terakhir, kita menambahkan byte padding yang diperlukan agar offset sejajar dengan 512 byte, yang dapat dilakukan dengan mudah dengan membagi panjang file dengan 512, mengambil batas atas angka, lalu mengalikan 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 mudah disesuaikan dengan codebase yang ada. Oleh karena itu, saya memilih untuk menulis sendiri. Saya juga meluangkan waktu untuk mengoptimalkan pemuatan sebaik mungkin, dan memastikan bahwa dekoder dapat menangani data biner dan string dengan mudah dalam arsip.

Salah satu masalah pertama yang harus saya pecahkan adalah cara memuat data 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 tersebut juga tidak terlalu cepat. Mengonversi ke objek Image juga tidak mudah.

Saya memutuskan untuk 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 hal ini dapat diperbaiki setelah API konversi yang lebih praktis tersedia di browser.

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

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

FileSystem API

Untuk benar-benar menyimpan konten file dan mengaksesnya nanti, kita menggunakan FileSystem API. API ini cukup baru, tetapi sudah memiliki beberapa dokumentasi yang bagus, termasuk artikel HTML5 Rocks FileSystem yang sangat bagus.

FileSystem API tidak tanpa kekurangan. Pertama, ini adalah antarmuka berbasis peristiwa; hal ini membuat API tidak memblokir, yang bagus untuk UI, tetapi juga membuatnya sulit digunakan. Menggunakan FileSystem API dari WebWorker dapat mengurangi masalah ini, tetapi hal itu akan memerlukan pemisahan seluruh sistem download dan ekstrak ke dalam WebWorker. Ini mungkin merupakan pendekatan terbaik, tetapi bukan pendekatan yang saya gunakan karena keterbatasan waktu (saya belum terbiasa dengan WorkWorkers), jadi saya harus menangani sifat API yang berbasis peristiwa asinkron.

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

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

Untuk aplikasi yang menargetkan Chrome Web Store, tidak ada batas penyimpanan saat menggunakan izin unlimitedStorage dalam file manifes aplikasi. Namun, aplikasi web reguler masih 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
  );
}