SONAR, Pengembangan Game HTML5

Sean Middleditch
Sean Middleditch

Musim panas lalu, saya bekerja sebagai pemimpin teknis dalam game WebGL komersial bernama SONAR. Proyek ini memerlukan waktu sekitar tiga bulan untuk diselesaikan, dan dilakukan sepenuhnya dari awal di JavaScript. Selama pengembangan SONAR, kami harus menemukan solusi inovatif untuk sejumlah masalah di HTML5 yang baru dan belum teruji. Secara khusus, kami memerlukan solusi untuk masalah yang tampaknya sederhana: bagaimana cara mendownload dan menyimpan dalam cache data game sebesar 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 mengemas 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 kita butuhkan untuk membangun sistem download resource game kita sendiri. Keuntungan membangun sistem kita sendiri adalah kita mendapatkan semua kontrol dan fleksibilitas yang kita butuhkan, dan dapat membangun sistem yang persis sesuai dengan kebutuhan kita.

Retrieval

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

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

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

Callback oncomplete yang dilampirkan ke instance ResourceLoader utama hanya akan dipanggil setelah semua resource dimuat. Layar pemuatan game dapat menunggu hingga 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 patut diselidiki adalah menambahkan dukungan progres/persentase, menambahkan pemuatan gambar (menggunakan jenis Gambar), menambahkan penguraian otomatis file JSON, dan tentu saja, penanganan error.

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

Mengemas Resource

Salah satu masalah dengan pemuatan resource yang 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 melakukan caching, dan hal ini dapat memakan waktu yang cukup lama untuk game yang lebih besar, memberikan dialog progres yang bagus kepada pemain cukup penting.

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

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

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

Kolom header dalam format TAR disimpan di lokasi tetap dengan ukuran tetap dalam 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 guna menunjukkan basis oktal yang diinginkan.

Salah satu kolom terpenting adalah kolom jenis. Ini adalah angka oktal satu digit yang memberi tahu kita jenis file yang berisi catatan. Dua jenis rekaman yang menarik untuk tujuan kita adalah file reguler ('0') dan direktori ('5'). Jika kita berurusan dengan file TAR arbitrer, kita juga mungkin memperhatikan 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 kontennya sendiri, seperti direktori). Konten file kemudian diikuti dengan padding untuk memastikan bahwa setiap header dimulai pada batas 512 byte. Jadi, untuk menghitung total panjang rekaman 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, kami menambahkan byte padding yang diperlukan untuk membuat offset sejajar dengan 512 byte, yang dapat dilakukan dengan mudah dengan membagi panjang file dengan 512, mengambil angka terbesarnya, lalu 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 sudah ada, dan menemukan beberapa, tetapi tidak ada yang tidak memiliki dependensi lain atau yang akan mudah dimasukkan ke dalam codebase yang ada. Oleh karena itu, saya memilih untuk menulisnya 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 menggunakan 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 sama sulitnya.

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 untuk mengurai header rekaman, yang mencakup semua kolom header TAR yang relevan (dan beberapa yang tidak terlalu relevan) serta lokasi dan ukuran data file dalam ArrayBuffer. Kode juga dapat secara opsional mengekstrak data sebagai tampilan ArrayBuffer dan menyimpannya dalam daftar header rekaman yang ditampilkan.

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

FileSystem API

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

FileSystem API memiliki peringatan. Salah satunya, API ini adalah antarmuka berbasis peristiwa; hal ini membuat API tidak memblokir yang sangat 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 pembongkaran ke dalam WebWorker. Itu bahkan mungkin menjadi pendekatan terbaik, tetapi bukan yang saya pilih karena keterbatasan waktu (saya belum memahami WorkWorkers), jadi saya harus berurusan dengan sifat API yang asinkron dan berbasis peristiwa.

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, dengan membuat direktori secara rekursif (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 kami tepat sebelum mencoba memuat file yang dikeluarkan.

Untuk aplikasi yang menargetkan Chrome Web Store, tidak ada batasan 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
  );
}