SONAR, HTML5 Game Development

Sean Middleditch
Sean Middleditch

Geçtiğimiz yaz, SONAR adlı ticari bir WebGL oyununda teknik yönetici olarak çalıştım. Yaklaşık üç ayda tamamlanan proje, JavaScript kullanılarak tamamen sıfırdan geliştirildi. SONAR'ı geliştirirken yeni ve denenmemiş HTML5 ortamındaki bir dizi soruna yenilikçi çözümler bulmamız gerekti. Özellikle de görünüşte basit olan bir soruna çözüm bulmamız gerekiyordu: Oyuncu oyuna başladığında 70 MB'tan fazla oyun verisini nasıl indirip önbelleğe alabiliriz?

Diğer platformlarda bu sorun için hazır çözümler bulunur. Çoğu konsol ve PC oyunu, kaynakları yerel bir CD/DVD'den veya sabit sürücüden yükler. Flash, tüm kaynakları oyunu içeren SWF dosyasının bir parçası olarak paketleyebilir. Java da JAR dosyalarıyla aynı işlemi yapabilir. Steam veya App Store gibi dijital dağıtım platformları, oyuncu oyuna başlamadan önce tüm kaynakların indirilip yüklenmesini sağlar.

HTML5 bu mekanizmaları bize sunmaz ancak kendi oyun kaynağı indirme sistemimizi oluşturmak için ihtiyacımız olan tüm araçları sağlar. Kendi sistemimizi oluşturmanın avantajı, ihtiyacımız olan tüm kontrol ve esnekliğe sahip olmamız ve tam olarak ihtiyaçlarımıza uygun bir sistem oluşturabilmemizdir.

Alma

Kaynak önbelleğe alma özelliğimiz yokken basit bir zincirleme kaynak yükleyicimiz vardı. Bu sistem, göreli yola göre tek tek kaynak istememize olanak tanıyordu. Bu da daha fazla kaynak istenmesine yol açabiliyordu. Yükleme ekranımızda, yüklenmesi gereken veri miktarını ölçen basit bir ilerleme ölçer gösteriliyordu ve yalnızca kaynak yükleyici sırası boşaldıktan sonra bir sonraki ekrana geçiliyordu.

Bu sistemin tasarımı, paketlenmiş kaynaklar ile yerel bir HTTP sunucusu üzerinden sunulan gevşek (paketlenmemiş) kaynaklar arasında kolayca geçiş yapmamıza olanak tanıdı. Bu da hem oyun kodu hem de veriler üzerinde hızlı bir şekilde yineleme yapabilmemizi sağlamada gerçekten etkili oldu.

Aşağıdaki kod, zincirleme kaynak yükleyicimizin temel tasarımını gösterir. Okunabilirliği korumak için hata işleme ve daha gelişmiş XHR/resim yükleme kodu kaldırılmıştır.

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

Bu arayüzün kullanımı oldukça basit ancak aynı zamanda oldukça esnektir. İlk oyun kodu, ilk oyun seviyesini ve oyun nesnelerini açıklayan bazı veri dosyaları isteyebilir. Örneğin, bunlar basit JSON dosyaları olabilir. Bu dosyalar için kullanılan geri arama, verileri inceler ve bağımlılıklar için ek istekler (zincirleme istekler) gönderebilir. Oyun nesneleri tanım dosyasında modeller ve materyaller listelenebilir. Materyaller için geri çağırma işlemi, doku resimleri isteyebilir.

Ana ResourceLoader örneğine eklenen oncomplete geri çağırma işlevi yalnızca tüm kaynaklar yüklendikten sonra çağrılır. Oyun yükleme ekranı, bir sonraki ekrana geçmeden önce bu geri çağırmanın çağrılmasını bekleyebilir.

Elbette bu arayüzle çok daha fazlasını yapabilirsiniz. Okuyucu için alıştırma olarak, incelenmeye değer birkaç ek özellik şunlardır: ilerleme/yüzde desteği ekleme, resim yükleme (Image türünü kullanarak) ekleme, JSON dosyalarının otomatik olarak ayrıştırılmasını ekleme ve elbette hata işleme.

Bu makale için en önemli özellik, istediğimiz dosyaların kaynağını kolayca değiştirmemize olanak tanıyan baseurl alanıdır. Temel motoru, parametre ayarlanmamışsa önbellek sistemini kullanırken oyuna ait ana HTML dokümanını sunan yerel web sunucusu (python -m SimpleHTTPServer gibi) tarafından sunulan bir URL'den kaynak istemek için URL'de ?uselocal türünde bir sorgu parametresine izin verecek şekilde ayarlamak kolaydır.

Paketleme Kaynakları

Kaynakların zincirleme yüklenmesiyle ilgili bir sorun, tüm verilerin tam bayt sayısını elde etmenin mümkün olmamasıdır. Bunun sonucu olarak, indirmeler için basit ve güvenilir bir ilerleme iletişim kutusu oluşturmak mümkün değildir. Tüm içerikleri indirip önbelleğe alacağımız için (bu işlem daha büyük oyunlarda oldukça uzun sürebilir) oyuncuya güzel bir ilerleme durumu iletişim kutusu göstermek oldukça önemlidir.

Bu sorunu düzeltmenin en kolay yolu (aynı zamanda bize başka avantajlar da sağlar) tüm kaynak dosyalarını tek bir pakette toplamaktır. Bu paketi tek bir XHR çağrısıyla indiririz. Bu da bize güzel bir ilerleme çubuğu göstermek için gereken ilerleme etkinliklerini sağlar.

Özel bir paket dosyası biçimi oluşturmak çok zor olmasa da ve hatta birkaç sorunu çözse de paket biçimini oluşturmak için bir araç oluşturulması gerekir. Alternatif bir çözüm olarak, araçları zaten mevcut olan bir arşiv biçimi kullanabilir ve ardından tarayıcıda çalışacak bir kod çözücü yazabilirsiniz. HTTP, verileri gzip veya deflate algoritmalarını kullanarak zaten sorunsuz bir şekilde sıkıştırabildiğinden sıkıştırılmış arşiv biçimine ihtiyacımız yoktur. Bu nedenlerle TAR dosya biçimini tercih ettik.

TAR, nispeten basit bir biçimdir. Her kaydın (dosya) 512 baytlık bir üstbilgisi vardır. Bunu, 512 bayta kadar doldurulmuş dosya içeriği izler. Üstbilgide, amacımız için yalnızca birkaç alakalı veya ilginç alan vardır. Bunlar, üstbilgi içinde sabit konumlarda depolanan dosya türü ve adıdır.

TAR biçimindeki üstbilgi alanları, üstbilgi bloğunda sabit boyutlarda ve sabit konumlarda saklanır. Örneğin, dosyanın son değiştirilme zaman damgası, üstbilginin başlangıcından itibaren 136 baytlık bir alanda saklanır ve 12 bayt uzunluğundadır. Tüm sayısal alanlar, ASCII biçiminde depolanan sekizlik sayılar olarak kodlanır. Alanları ayrıştırmak için alanları dizi arabelleğimizden çıkarırız ve sayısal alanlar için parseInt() işlevini çağırırız. İkinci parametreyi, istenen sekizlik tabanı belirtecek şekilde ilettiğimizden emin oluruz.

En önemli alanlardan biri tür alanıdır. Bu, kaydın hangi tür dosyayı içerdiğini gösteren tek haneli bir sekizlik sayıdır. Amacımız doğrultusunda ilgileneceğimiz iki kayıt türü vardır: normal dosyalar ('0') ve dizinler ('5'). Rastgele TAR dosyalarıyla çalışıyor olsaydık sembolik bağlantılar ('2') ve muhtemelen sabit bağlantılar ('1') da ilgimizi çekebilirdi.

Her başlığı, başlık tarafından açıklanan dosyanın içeriği hemen takip eder (kendi içeriği olmayan dosya türleri hariç, örneğin dizinler). Ardından, her başlığın 512 baytlık bir sınırda başlamasını sağlamak için dosya içeriğine dolgu eklenir. Bu nedenle, bir TAR dosyasındaki dosya kaydının toplam uzunluğunu hesaplamak için önce dosyanın başlığını okumamız gerekir. Ardından, başlığın uzunluğunu (512 bayt) başlıkta çıkarılan dosya içeriklerinin uzunluğuyla toplarız. Son olarak, dosya uzunluğunu 512'ye bölüp sayının tavanını alarak ve ardından 512 ile çarparak kolayca yapılabilen, ofsetin 512 bayta hizalanması için gereken dolgu baytlarını ekleriz.

// 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
  };
};

Mevcut TAR okuyucularını araştırdım ve birkaç tane buldum ancak hiçbiri başka bağımlılıkları olmayan veya mevcut kod tabanımıza kolayca uyacak bir okuyucu değildi. Bu nedenle, kendi yanıtımı yazmayı tercih ettim. Ayrıca yüklemeyi mümkün olduğunca optimize ettim ve kod çözücünün, arşivdeki hem ikili hem de dize verilerini kolayca işlemesini sağladım.

Çözmem gereken ilk sorunlardan biri, verilerin XHR isteğinden nasıl yükleneceğiydi. Başlangıçta "ikili dize" yaklaşımını kullandım. Maalesef, ikili dizeleri ArrayBuffer gibi daha kolay kullanılabilir ikili biçimlere dönüştürmek kolay değildir ve bu tür dönüşümler özellikle hızlı değildir. Image nesnelerine dönüştürmek de aynı derecede zahmetlidir.

TAR dosyalarını doğrudan XHR isteğinden ArrayBuffer olarak yüklemeye ve ArrayBuffer'daki parçaları dizeye dönüştürmek için küçük bir kolaylık işlevi eklemeye karar verdim. Kodum şu anda yalnızca temel ANSI/8 bit karakterleri işliyor ancak tarayıcılarda daha uygun bir dönüştürme API'si kullanıma sunulduğunda bu sorun düzeltilebilir.

Kod, ArrayBuffer içinde dosya verilerinin konumunu ve boyutunu, ilgili tüm TAR başlık alanlarını (ve çok da ilgili olmayan birkaç alanı) içeren kayıt başlıklarını ayrıştırarak tarar.ArrayBuffer Kod, verileri isteğe bağlı olarak ArrayBuffer görünümü olarak da çıkarabilir ve döndürülen kayıt başlıkları listesinde saklayabilir.

Kod, https://github.com/subsonicllc/TarReader.js adresinde kullanıcı dostu ve izin verici bir açık kaynak lisansı kapsamında ücretsiz olarak sunulmaktadır.

FileSystem API

Dosya içeriklerini depolamak ve daha sonra bunlara erişmek için FileSystem API'yi kullandık. API oldukça yeni olmasına rağmen, mükemmel HTML5 Rocks FileSystem makalesi de dahil olmak üzere bazı harika dokümanları var.

FileSystem API'nin bazı sınırlamaları vardır. Öncelikle, etkinlik odaklı bir arayüzdür. Bu durum, API'yi engellemeyen bir arayüz haline getirir. Bu da kullanıcı arayüzü için harika bir özellik olsa da kullanımı zorlaştırır. FileSystem API'yi bir WebWorker'dan kullanmak bu sorunu hafifletebilir ancak bu durumda tüm indirme ve paket açma sisteminin bir WebWorker'a bölünmesi gerekir. Bu, en iyi yaklaşım olabilir ancak zaman kısıtlamaları nedeniyle (WorkWorkers'ı henüz bilmiyordum) bu yaklaşımı kullanmadım ve API'nin eşzamansız, etkinliğe dayalı yapısıyla uğraşmak zorunda kaldım.

İhtiyaçlarımız daha çok dosyaları bir dizin yapısına yazmaya odaklanıyor. Bu işlem için her dosya için bir dizi adım gerekir. Öncelikle dosya yolunu alıp listeye dönüştürmemiz gerekir. Bu işlem, yol dizesini yol ayırıcı karakterle (URL'lerde olduğu gibi her zaman eğik çizgi) bölerek kolayca yapılabilir. Ardından, sonuçtaki listede sonuncusu hariç her öğe üzerinde yineleme yapmamız ve gerekirse yerel dosya sisteminde yinelemeli olarak bir dizin oluşturmamız gerekir. Ardından dosyayı oluşturabilir, FileWriter oluşturabilir ve son olarak dosya içeriklerini yazabiliriz.

Göz önünde bulundurulması gereken ikinci önemli nokta, FileSystem API'nin PERSISTENT depolama alanının dosya boyutu sınırıdır. Geçici depolama alanı, kullanıcı oyunumuzu oynarken de dahil olmak üzere herhangi bir zamanda temizlenebildiği için kalıcı depolama alanı istedik. Bu durum, çıkarılan dosya yüklenmeye çalışılmadan hemen önce gerçekleşebilir.

Chrome Web Mağazası'nı hedefleyen uygulamalarda, uygulamanın manifest dosyasında unlimitedStorage izni kullanılırken depolama alanı sınırı yoktur. Ancak normal web uygulamaları, deneysel kota isteği arayüzüyle alan isteğinde bulunmaya devam edebilir.

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