Giriş
Geçtiğimiz yaz, SONAR adlı ticari bir WebGL oyununda teknik yönetici olarak çalıştım. Projenin tamamlanması yaklaşık üç ay sürdü ve tamamen sıfırdan JavaScript'te yazıldı. SONAR'ı geliştirirken yeni ve test edilmemiş HTML5 ortamındaki çeşitli sorunlara yenilikçi çözümler bulmak zorunda kaldık. Özellikle, basit görünen bir soruna çözüm üretmemiz gerekiyordu: Oyuncu oyunu başlattığında 70 MB'dan fazla oyun verisini nasıl indirip önbelleğe alacağız?
Diğer platformlarda bu sorun için hazır çözümler vardır. Çoğu konsol ve PC oyunu, kaynakları yerel bir CD/DVD'den veya sabit diskten yükler. Flash, tüm kaynakları oyunu içeren SWF dosyasının bir parçası olarak paketleyebilir. Java da JAR dosyalarında 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 bize bu mekanizmaları sunmasa da kendi oyun kaynağı indirme sistemimizi oluşturmak için ihtiyaç duyduğumuz tüm araçları sunar. Kendi sistemimizi oluşturmanın avantajı, ihtiyaç duyduğumuz tüm denetimi ve esnekliği elde etmemiz ve ihtiyaçlarımıza tam olarak uyan bir sistem oluşturabilmemizdir.
Alma
Kaynak önbelleğe alma özelliğini kullanmadan önce basit bir zincirlenmiş kaynak yükleyicimiz vardı. Bu sistem, göreli yola göre tek tek kaynak isteğinde bulunmamıza olanak tanıdı. Bu da daha fazla kaynak isteğinde bulunmamıza olanak tanıdı. Yükleme ekranımızda, ne kadar veri daha yüklenmesi gerektiğini gösteren basit bir ilerleme çubuğu vardı ve yalnızca kaynak yükleyici sırası boşaldıktan sonra 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 kodunda hem de verilerde hızlı iterasyon yapabilmemizi sağladı.
Aşağıdaki kod, zincirlenmiş kaynak yükleyicimizin temel tasarımını gösterir. Hata işleme ve daha gelişmiş XHR/resim yükleme kodu, okunabilirliği sağlamak için 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 ve 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 çağırma işlevi, bu verileri inceler ve bağımlılıklar için ek istekler (zincirlenmiş istekler) gönderebilir. Oyun nesnesi tanımı dosyası modelleri ve malzemeleri listeleyebilir. Ardından, malzemeler için geri çağırma işlevi 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ı, sonraki ekrana geçmeden önce bu geri çağırma işlevinin çağrılmasını bekleyebilir.
Elbette bu arayüzle çok daha fazla şey yapılabilir. Okuyucu için alıştırma olarak, ilerleme/yüzde desteği ekleme, resim yükleme ekleme (Resim türünü kullanarak), JSON dosyalarının otomatik olarak ayrıştırılmasını ekleme ve elbette hata işleme gibi birkaç ek özelliği incelemeye değer.
Bu makaledeki en önemli özellik, isteğimiz dosyaların kaynağını kolayca değiştirmemize olanak tanıyan baseurl alanıdır. Temel motoru, URL'deki ?uselocal
türünde bir sorgu parametresinin, oyunun ana HTML belgesini sunan aynı yerel web sunucusu (python -m SimpleHTTPServer
gibi) tarafından sunulan bir URL'den kaynak istemesine izin verecek şekilde ayarlamak kolaydır. Bu durumda, parametre ayarlanmamışsa önbellek sistemi kullanılır.
Paketleme Kaynakları
Kaynakların zincirleme yüklenmesiyle ilgili bir sorun, tüm verilerin tam bayt sayısının alınamamasıdır. Bunun sonucunda, indirmeler için basit ve güvenilir bir ilerleme iletişim kutusu oluşturmak mümkün değildir. Tüm içeriği indirip önbelleğe alacağız ve bu işlem büyük oyunlarda oldukça uzun sürebileceğinden, oyuncuya güzel bir ilerleme iletişim kutusu göstermek oldukça önemlidir.
Bu sorunun en kolay çözümü (ayrıca bize birkaç avantaj daha sağlar), tüm kaynak dosyalarını tek bir pakette paketlemektir. Bu paketi tek bir XHR çağrısıyla indiririz. Bu çağrı, güzel bir ilerleme çubuğu göstermek için ihtiyaç duyduğumuz ilerleme etkinliklerini bize sağlar.
Özel paket dosyası biçimi oluşturmak çok zor değildir ve hatta birkaç sorunu çözebilir. Ancak paket biçimini oluşturmak için bir araç oluşturmanız gerekir. Alternatif bir çözüm, araçları zaten mevcut olan mevcut bir arşiv biçimi kullanmaktır. Ardından, tarayıcıda çalışacak bir kod çözücü yazmanız gerekir. HTTP, gzip veya deflate algoritmalarını kullanarak verileri zaten iyi bir şekilde sıkıştırabildiğinden sıkıştırılmış bir arşiv biçimine ihtiyacımız yoktur. Bu nedenlerden dolayı TAR dosya biçimini tercih ettik.
TAR, nispeten basit bir biçimdir. Her kaydın (dosyanın) 512 baytlık bir başlığı ve ardından 512 bayta kadar doldurulan dosya içeriği vardır. Üstbilgede, amacımız doğrultusunda yalnızca birkaç alakalı veya ilgi çekici alan bulunur. Bunlar arasında, üstbilgi içinde sabit konumlarda depolanan dosya türü ve adı yer alır.
TAR biçimindeki üstbilgi alanları, üstbilgi bloğunda sabit boyutlarda sabit konumlarda depolanır. Örneğin, dosyanın son değişiklik zaman damgası, üstbilginin başlangıcından 136 bayt uzaklıkta depolanır ve 12 bayt uzunluğundadır. Tüm sayısal alanlar, ASCII biçiminde saklanan sekizlik sayılar olarak kodlanır. Ardından alanları ayrıştırmak için alanları dizi arabelleğimizden ayıklıyoruz ve sayısal alanlar için parseInt()
'yi çağırıyoruz. Bu sırada, istenen sekizlik tabanı belirtmek için ikinci parametreyi ilettiğimizden emin oluyoruz.
En önemli alanlardan biri tür alanıdır. Bu, kaydın ne tür bir dosya içerdiğini belirten tek haneli bir sekizlik sayı. Amacımız doğrultusunda ilgilendiğimiz tek iki kayıt türü normal dosyalar ('0'
) ve dizinler ('5'
) dir. İsteğe bağlı TAR dosyalarıyla ilgileniyorsak sembolik bağlantılar ('2'
) ve muhtemelen sabit bağlantılar ('1'
) da dikkate alabiliriz.
Her başlığın hemen ardından, başlıkta açıklanan dosyanın içeriği gelir (kendi içeriği olmayan dosya türleri (ör. dizinler) hariç). Ardından, her başlığın 512 baytlık bir sınırda başlamasını sağlamak için dosya içeriğinin ardından dolgu gelir. Bu nedenle, TAR dosyasındaki bir dosya kaydının toplam uzunluğunu hesaplamak için önce dosyanın başlığını okumamız gerekir. Ardından, başlıktan ayıklanan dosya içeriğinin uzunluğuna başlığın uzunluğunu (512 bayt) ekleriz. Son olarak, ofsetin 512 baytla hizalanmasını sağlamak için gerekli olan tüm dolgu baytlarını ekleriz. Bu işlem, dosya uzunluğunu 512'ye bölerek, sayının üst sınırını alarak ve ardından 512 ile çarparak kolayca yapılabilir.
// 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ı aradım ve birkaç tane buldum ancak bunların hiçbiri başka bağımlılıklar içermiyordu veya mevcut kod tabanımıza kolayca sığmıyordu. Bu nedenle, kendi hikayemi yazmaya karar verdim. Ayrıca, yüklemeyi mümkün olduğunca optimize etmek ve kod çözücünün arşivdeki hem ikili hem de dize verilerini kolayca işlemesini sağlamak için zaman ayırdım.
Çözüme ulaşması gereken ilk sorunlardan biri, verileri bir XHR isteğinde nasıl yükleyeceğiydi. Başlangıçta "ikili dize" yaklaşımını kullandım. Maalesef, ikili dizelerden ArrayBuffer
gibi daha kolay kullanılabilir ikili biçimlere dönüştürme işlemi kolay değildir ve bu tür dönüştürme işlemleri özellikle hızlı değildir. Image
nesnelerine dönüştürme işlemi de aynı derecede zordur.
TAR dosyalarını doğrudan XHR isteğinden ArrayBuffer
olarak yüklemeye ve ArrayBuffer
'deki parçaları bir dizeye dönüştürmek için küçük bir kolaylık işlevi eklemeye karar verdim. Şu anda kodum yalnızca temel ANSI/8 bit karakterleri işlemektedir ancak tarayıcılarda daha kullanışlı bir dönüşüm API'si kullanıma sunulduğunda bu sorun düzeltilebilir.
Kod, ArrayBuffer
içinde ilgili tüm TAR başlık alanlarının (ve çok alakalı olmayan birkaç alanın) yanı sıra dosya verilerinin konumunu ve boyutunu içeren kayıt başlıklarını ayrıştırarak ArrayBuffer
'yi tarar. Kod, isteğe bağlı olarak verileri ArrayBuffer
görünümü olarak da ayıklayabilir ve döndürülen kayıt üstbilgileri 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 ancak mükemmel HTML5 Rocks FileSystem makalesi de dahil olmak üzere bazı mükemmel dokümanları mevcut.
FileSystem API'nin bazı sakıncaları vardır. Öncelikle, etkinlik odaklı bir arayüz olması nedeniyle API'nin kullanıcı arayüzü için mükemmel olan ancak kullanımı zor olan bir özelliği vardır. FileSystem API'yi bir WebWorker'dan kullanmak bu sorunu hafifletebilir ancak bunun için indirme ve paket açma sisteminin tamamının bir WebWorker'a bölünmesi gerekir. Bu en iyi yaklaşım olabilirdi ancak zaman kısıtlamaları nedeniyle (WorkWorkers'ı henüz bilmiyordum) bu yaklaşımı benimsemedim. Bu nedenle API'nin asenkron, olaya dayalı yapısıyla uğraşmam gerekti.
İhtiyaçlarımız çoğunlukla dosyaları bir dizin yapısına yazmaya odaklanır. Bu işlem için her dosya için bir dizi adım gerekir. Öncelikle dosya yolunu alıp bir listeye dönüştürmemiz gerekir. Bu işlem, yol dizesini yol ayırıcı karakterde (URL'lerde olduğu gibi her zaman eğik çizgidir) bölerek kolayca yapılabilir. Ardından, sonuncu hariç olmak üzere, elde edilen listedeki her öğeyi iteratif olarak inceleyip yerel dosya sisteminde (gerekirse) bir dizin oluşturmamız gerekir. Ardından dosyayı oluşturup bir FileWriter
oluşturup son olarak dosya içeriğini 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, atılmış dosyayı yüklemeye çalışmadan hemen önce de dahil olmak üzere herhangi bir zamanda temizlenebileceğinden kalıcı depolama alanı kullanmak istedik.
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
);
}