Örnek Olay - SONAR, HTML5 Oyun Geliştirme

Selin Mehmet
Sean Middleditch

Giriş

Geçen yaz SONAR adlı ticari bir WebGL oyununda teknik liderlik yapmıştım. Projenin tamamlanması yaklaşık üç ay sürdü ve tamamen JavaScript'le sıfırdan gerçekleştirildi. SONAR'ı geliştirirken, yeni ve test edilmemiş HTML5 sularında bir dizi soruna yenilikçi çözümler bulmamız gerekti. Özellikle, görünüşte basit bir sorun için bir çözüme ihtiyacımız vardı: Oyuncu oyuna başladığında 70 MB'ın üzerinde oyun verilerini nasıl indirip önbelleğe alırız?

Diğer platformlarda bu sorun için kullanıma hazır çözümler bulunur. Çoğu konsol ve PC oyunu, kaynakları yerel 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 ve Java aynısını JAR dosyalarıyla yapabilir. Steam veya App Store gibi dijital dağıtım platformları, oyuncu daha oyuna başlamadan tüm kaynakların indirilip yüklenmesini sağlar.

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

Alma

Eskiden kaynakları önbelleğe alma işlevi olmayan basit bir zincirleme kaynak yükleyicimiz vardı. Bu sistem, göreli yola göre tek tek kaynaklar talep etmemize olanak tanıdı ve sonuç olarak daha fazla kaynak talep edebiliyordu. Yükleme ekranımızda, ne kadar veri yüklenmesi gerektiğini ölçen ve bir sonraki ekrana ancak kaynak yükleyici sırası boşaldıktan sonra geçen basit bir ilerleme sayacı sunuldu.

Bu sistemin tasarımı, paketlenmiş kaynaklar ile yerel bir HTTP sunucusu üzerinden sunulan gevşek (paketlenmemiş) kaynaklar arasında kolayca geçiş yapabilmemizi sağladı. Bu da hem oyun kodunu hem de verileri hızlı bir şekilde iterasyon yapabilmemizi sağlamada çok önemli bir rol oynadı.

Aşağıdaki kodda, zincirleme kaynak yükleyicimizin temel tasarımı gösterilmektedir. Temel tasarımda hata düzeltmesi ve içeriğin daha kolay okunabilmesi için 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 esnek. İlk oyun kodu, başlangıçtaki oyun seviyesini ve oyun nesnelerini açıklayan bazı veri dosyalarını isteyebilir. Örneğin, bunlar basit JSON dosyaları olabilir. Daha sonra bu dosyalar için kullanılan geri çağırma, verileri inceler ve bağımlılıklar için ek istekler (zincirli istekler) yapabilir. Oyun nesneleri tanım dosyasında modeller ve malzemeler listelenebilir ve malzemelerin geri çağırması, doku resimleri isteyebilir.

Ana ResourceLoader örneğine ekli oncomplete geri çağırması 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 birçok şey daha yapılabilir. Okuyucu alıştırması olarak ilerleme/yüzde desteği ekleme, resim yükleme (Resim türünü kullanarak), JSON dosyalarının otomatik olarak ayrıştırılması ve tabii ki hata işleme özelliklerinin eklenmesi, incelenmeye değer diğer özelliklerdir.

Bu makaledeki en önemli özellik, istediğimiz dosyaların kaynağını kolayca değiştirmemize olanak tanıyan baseurl alanıdır. Temel motoru, URL'de ?uselocal türündeki bir sorgu parametresinin, oyunun ana HTML dokümanını sunan aynı yerel Web sunucusu (python -m SimpleHTTPServer gibi) tarafından sunulan bir URL'den kaynak istemesine izin verecek ve parametre ayarlanmamışsa önbellek sistemini kullanacak şekilde kolayca ayarlanabilir.

Paketleme Kaynakları

Kaynakların zincirleme yüklenmesiyle ilgili sorunlardan biri, tüm verilerin bayt cinsinden tam sayısını almanın mümkün olmamasıdır. Bunun sonucunda, indirme işlemleri için basit ve güvenilir bir ilerleme durumu iletişim kutusu oluşturmanın bir yolu yoktur. Tüm içeriği indireceğimiz ve önbelleğe alacağımızdan ve bu işlem büyük oyunlarda oldukça uzun sürebileceğinden, oynatıcıya iyi bir ilerleme diyalogu sunmak çok önemlidir.

Bu sorunun en kolay çözümü (ayrıca bize birkaç başka avantaj da sunar) tüm kaynak dosyalarını tek bir XHR çağrısıyla indireceğimiz tek bir paket halinde paketlemektir. Bu paket, güzel bir ilerleme çubuğu görüntülememiz için ihtiyacımız olan ilerleme etkinliklerini bize verir.

Özel bir paket dosyası biçimi oluşturmak o kadar da zor değildir ve birkaç sorunu bile çözer ancak paket biçimini oluşturmak için bir araç oluşturmayı gerektirir. Alternatif bir çözüm de, araçların zaten mevcut olduğu ve daha sonra tarayıcıda çalışması için bir kod çözücü yazmanın gerekli olduğu mevcut bir arşiv biçimini kullanmaktır. Sıkıştırılmış arşiv biçimine ihtiyacımız yoktur. Çünkü HTTP, verileri gzip kullanarak zaten sıkıştırabilir veya algoritmaları söndürebilir. Bu nedenlerden dolayı, TAR dosya biçimini kullanmaya karar verdik.

TAR nispeten basit bir biçimdir. Her kaydın (dosya) 512 baytlık bir başlığı ve ardından 512 bayta kadar doldurulan dosya içeriği vardır. Üstbilgide, amaçlarımıza uygun olarak yalnızca dosya türü ve adı olmak üzere başlık içinde sabit konumlarda depolanan birkaç alakalı veya ilgi çekici alan bulunur.

TAR biçimindeki başlık alanları, sabit boyutlarda başlık bloğunda saklanır. Örneğin, dosyanın son değişiklik zaman damgası, başlığın başlangıcından itibaren 136 baytta depolanı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, dizi arabelleğimizden alanları alırız ve sayısal alanlar için, istenen sekizlik tabanı belirtmek üzere ikinci parametreye geçirildiğinden emin olarak parseInt() adını veririz.

En önemli alanlardan biri tür alanıdır. Bu, kaydın hangi dosya türünü içerdiğini gösteren tek haneli sekizlik bir sayıdır. Amaçlarımız açısından ilginç olan iki kayıt türü normal dosyalar ('0') ve dizinlerdir ('5'). Rastgele TAR dosyaları üzerinde çalışıyor olsaydık sembolik bağlantılar ('2') ve muhtemelen sabit bağlantılar da ('1') sizin için önemliydi.

Her başlığın hemen ardından, başlıkta açıklanan dosyanın içeriği gelir (dizinler gibi kendilerine ait içeriği olmayan dosya türleri hariç). Her başlığın 512 baytlık bir sınırda başladığından emin olmak için dosya içeriklerinin ardından dolgu eklenir. Dolayısıyla, TAR dosyasındaki bir dosya kaydının toplam uzunluğunu hesaplamak için önce dosyanın başlığını okumamız gerekir. Daha sonra, üstbilgiden alınan dosya içeriğinin uzunluğuyla başlığın uzunluğunu (512 bayt) ekleriz. Son olarak, uzaklığı 512 bayt olarak hizalamak için gereken tüm dolgu baytlarını ekleriz. Bunu, dosya uzunluğunu 512’ye bölüp sayının tavanını alıp ardından 512 ile çarparak kolayca yapabilirsiniz.

// 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ı inceledim ve birkaç tane buldum. Ancak başka bağımlılıkları olmayan veya mevcut kod tabanımıza kolayca sığabilecek hiçbir okuyucu yoktu. Bu nedenle kendimi yazmayı tercih ettim. Ayrıca yüklemeyi mümkün olan en iyi şekilde optimize etmeye ve kod çözücünün arşiv içindeki hem ikili hem de dize verilerini kolayca işlemesini sağlamaya zaman ayırdım.

Çözmem gereken ilk sorunlardan biri, bir XHR isteğinden yüklenen verilerin nasıl elde edileceğiydi. İlk başta "ikili dize" yaklaşımıyla başladım. Ne yazık ki, ikili dizelerden ArrayBuffer gibi daha kolay kullanılabilen ikili biçimlere dönüştürme süreci kolay değildir ve bu tür dönüşümler özellikle hızlı değildir. Image nesnelere dönüştürmek de aynı derecede zahmetli bir işlemdir.

TAR dosyalarını, doğrudan XHR isteğinden ArrayBuffer olarak yüklemeye ve parçaları ArrayBuffer öğesinden 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üşüm API'si kullanılabilir olduğunda bu sorun düzeltilebilir.

Kod sadece ArrayBuffer içinde tarama yaparak kayıt başlıklarını ayrıştırır. Bu başlıklar, tüm alakalı TAR başlık alanlarını (ve alakasız birkaç alanı) ve ArrayBuffer içindeki dosya verilerinin konumunu ve boyutunu içerir. Kod ayrıca, verileri isteğe bağlı olarak ArrayBuffer görünümü olarak çıkarabilir ve döndürülen kayıt üstbilgileri listesinde saklayabilir.

Bu kod, https://github.com/subsonicllc/TarReader.js adresinde, sorunsuz ve geniş kapsamlı bir Açık Kaynak lisansıyla ücretsiz olarak kullanılabilir.

Dosya Sistemi API'sı

Dosya içeriklerini depolamak ve daha sonra erişmek için FileSystem API'sını kullandık. API oldukça yenidir ancak HTML5 Rocks Dosya Sistemi makalesi dahil olmak üzere çok sayıda faydalı doküman içerir.

FileSystem API'nin uyarıları var. Birincisi, olay odaklı bir arayüz olması. Bu, API'nin engellenmemesini sağlar. Bu da kullanıcı arayüzü için çok iyi bir şeydir, aynı zamanda kullanımı da zahmetli hale getirir. Bir WebWorker'dan FileSystem API'sini kullanmak bu sorunu giderebilir, ancak bunun için indirme ve paketi açma işleminin tamamını bir WebWorker'a bölmeniz gerekir. Hatta en iyi yaklaşım bu olsa da zaman kısıtlamaları nedeniyle tercih ettiğim yaklaşım bu değildi (WorkWorkers'ı henüz bilmiyordum). Bu nedenle API'nin eşzamansız ve etkinliğe dayalı doğasıyla uğraşmak zorunda kaldım.

İhtiyaçlarımız çoğunlukla dosyaların bir dizin yapısına yazılmasına odaklanmıştır. Bu işlem, her dosya için bir dizi adım gerektirir. Öncelikle, dosya yolunu alıp bir listeye dönüştürmemiz gerekir. Bu işlemi, yol ayırıcı karakterindeki (URL'ler gibi her zaman eğik çizgidir) bölerek kolayca yapabilirsiniz. Daha sonra, yerel dosya sisteminde yinelemeli olarak bir dizin oluşturarak (gerekirse) son kayıt için sonuç listesindeki her öğeyi yinelememiz gerekir. Ardından, dosyayı oluşturabilir ve ardından bir FileWriter oluşturup son olarak dosya içeriğini yazabiliriz.

Dikkat edilmesi 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ı çıkarılan dosyayı yüklemeye çalışmadan hemen önce oyun ortasındayken de dahil olmak üzere herhangi bir zamanda temizlenebildiği için kalıcı depolama alanı istedik.

Chrome Web Mağazası'nı hedefleyen uygulamalar için, 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
  );
}