केस स्टडी - SONAR, HTML5 गेम डेवलपमेंट

Sean Middleditch
Sean Middleditch

परिचय

पिछली गर्मियों में, मैंने SONAR नाम के कमर्शियल WebGL गेम में तकनीकी लीडर के तौर पर काम किया था. इस प्रोजेक्ट को पूरा करने में करीब तीन महीने लगे. इसे पूरी तरह से JavaScript में बनाया गया था. SONAR को डेवलप करने के दौरान, हमें नए और बिना जांचे गए एचटीएमएल5 में कई समस्याओं के लिए, नए तरीके ढूंढने पड़े. खास तौर पर, हमें एक ऐसी समस्या का हल चाहिए था जो दिखने में आसान थी: जब कोई खिलाड़ी गेम शुरू करता है, तो हम 70 एमबी से ज़्यादा गेम डेटा को कैसे डाउनलोड और कैश मेमोरी में सेव करते हैं?

अन्य प्लैटफ़ॉर्म पर, इस समस्या के लिए पहले से ही समाधान मौजूद हैं. ज़्यादातर कंसोल और पीसी गेम, लोकल सीडी/डीवीडी या हार्ड-ड्राइव से संसाधन लोड करते हैं. Flash, गेम वाली SWF फ़ाइल के हिस्से के तौर पर सभी संसाधनों को पैकेज कर सकता है. Java भी JAR फ़ाइलों के साथ ऐसा कर सकता है. Steam या App Store जैसे डिजिटल डिस्ट्रिब्यूशन प्लैटफ़ॉर्म यह पक्का करते हैं कि गेम शुरू करने से पहले, सभी संसाधन डाउनलोड और इंस्टॉल हो जाएं.

HTML5 में ये सुविधाएं नहीं होतीं. हालांकि, इसमें हमें अपने गेम के संसाधनों को डाउनलोड करने का सिस्टम बनाने के लिए ज़रूरी सभी टूल मिलते हैं. अपना सिस्टम बनाने का फ़ायदा यह है कि हमें ज़रूरत के मुताबिक पूरा कंट्रोल और सुविधाएं मिलती हैं. साथ ही, हम अपनी ज़रूरतों के हिसाब से सिस्टम बना सकते हैं.

डेटा वापस पाना

संसाधन कैश मेमोरी में सेव करने की सुविधा शुरू होने से पहले, हमारे पास एक आसान चेन वाला संसाधन लोडर था. इस सिस्टम की मदद से, हम रिलेटिव पाथ के हिसाब से अलग-अलग संसाधनों का अनुरोध कर सकते थे. इससे ज़्यादा संसाधनों का अनुरोध किया जा सकता था. हमारी लोडिंग स्क्रीन पर एक आसान प्रोग्रेस मेटर दिखाया गया था. इससे यह पता चलता था कि कितना डेटा लोड करना बाकी है. साथ ही, रिसॉर्स लोडर की सूची खाली होने के बाद ही अगली स्क्रीन पर ट्रांज़िशन किया जाता था.

इस सिस्टम के डिज़ाइन की मदद से, हम स्थानीय एचटीटीपी सर्वर पर पैकेज किए गए संसाधनों और अनपैक किए गए संसाधनों के बीच आसानी से स्विच कर सकते थे. इससे हमें यह पक्का करने में मदद मिली कि हम गेम कोड और डेटा, दोनों पर तेज़ी से काम कर सकें.

यहां दिया गया कोड, चेन वाले हमारे संसाधन लोडर के बुनियादी डिज़ाइन को दिखाता है. इसमें गड़बड़ी को मैनेज करने और XHR/इमेज लोड करने के बेहतर कोड को हटा दिया गया है, ताकि इसे आसानी से पढ़ा जा सके.

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

इस इंटरफ़ेस का इस्तेमाल करना बहुत आसान है. साथ ही, इसमें कई तरह के बदलाव किए जा सकते हैं. गेम का शुरुआती कोड, कुछ डेटा फ़ाइलों का अनुरोध कर सकता है. इन फ़ाइलों में, गेम के शुरुआती लेवल और गेम ऑब्जेक्ट के बारे में जानकारी होती है. उदाहरण के लिए, ये सामान्य JSON फ़ाइलें हो सकती हैं. इसके बाद, इन फ़ाइलों के लिए इस्तेमाल किया गया कॉलबैक उस डेटा की जांच करता है और डिपेंडेंसी के लिए अतिरिक्त अनुरोध (चेन किए गए अनुरोध) कर सकता है. गेम ऑब्जेक्ट की डेफ़िनिशन फ़ाइल में मॉडल और मटीरियल की सूची हो सकती है. इसके बाद, मटीरियल के लिए कॉलबैक, टेक्सचर इमेज का अनुरोध कर सकता है.

मुख्य ResourceLoader इंस्टेंस से जुड़े oncomplete कॉलबैक को सिर्फ़ तब कॉल किया जाएगा, जब सभी संसाधन लोड हो जाएंगे. गेम लोड होने की स्क्रीन, अगली स्क्रीन पर जाने से पहले, उस कॉलबैक के ट्रिगर होने का इंतज़ार कर सकती है.

इस इंटरफ़ेस की मदद से, ज़्यादा काम किए जा सकते हैं. पाठकों के लिए कुछ और सुविधाएं भी हैं जिनकी जांच की जा सकती है. जैसे, प्रोग्रेस/प्रतिशत की सुविधा जोड़ना, इमेज लोड करने की सुविधा जोड़ना (इमेज टाइप का इस्तेमाल करके), JSON फ़ाइलों को अपने-आप पार्स करने की सुविधा जोड़ना, और गलतियों को ठीक करना.

इस लेख के लिए सबसे ज़रूरी फ़ील्ड, baseurl फ़ील्ड है. इसकी मदद से, हम उन फ़ाइलों के सोर्स को आसानी से स्विच कर सकते हैं जिनका अनुरोध किया जाता है. कोर इंजन को सेट अप करना आसान है, ताकि यूआरएल में ?uselocal टाइप के क्वेरी पैरामीटर की अनुमति दी जा सके. इससे, उसी लोकल वेब सर्वर (जैसे कि python -m SimpleHTTPServer) से दिखाए गए यूआरएल से संसाधनों का अनुरोध किया जा सकता है जिसने गेम के मुख्य एचटीएमएल दस्तावेज़ को दिखाया था. साथ ही, पैरामीटर सेट न होने पर, कैश मेमोरी सिस्टम का इस्तेमाल किया जा सकता है.

पैकेजिंग से जुड़े संसाधन

रिसॉर्स को चेन में लोड करने की एक समस्या यह है कि पूरे डेटा का बाइट काउंट पाने का कोई तरीका नहीं है. इस वजह से, डाउनलोड की प्रोग्रेस दिखाने वाला आसान और भरोसेमंद डायलॉग बनाने का कोई तरीका नहीं है. हम पूरा कॉन्टेंट डाउनलोड करके उसे कैश मेमोरी में सेव करेंगे. बड़े गेम के लिए, इसमें काफ़ी समय लग सकता है. इसलिए, गेम में हुई प्रोग्रेस के बारे में खिलाड़ी को जानकारी देना ज़रूरी है.

इस समस्या को ठीक करने का सबसे आसान तरीका यह है कि सभी रिसॉर्स फ़ाइलों को एक बंडल में पैकेज कर दिया जाए. हम इसे एक XHR कॉल के साथ डाउनलोड करेंगे. इससे हमें प्रगति के ऐसे इवेंट मिलते हैं जिनकी मदद से प्रगति बार दिखाया जा सकता है.

कस्टम बंडल फ़ाइल फ़ॉर्मैट बनाना बहुत मुश्किल नहीं है. इससे कुछ समस्याएं भी हल हो सकती हैं. हालांकि, इसके लिए बंडल फ़ॉर्मैट बनाने वाला टूल बनाना होगा. इसके अलावा, किसी ऐसे मौजूदा संग्रह फ़ॉर्मैट का इस्तेमाल किया जा सकता है जिसके लिए टूल पहले से मौजूद हों. इसके बाद, ब्राउज़र में चलाने के लिए डिकोडर लिखना होगा. हमें कंप्रेस किए गए संग्रह फ़ॉर्मैट की ज़रूरत नहीं है, क्योंकि एचटीटीपी पहले से ही gzip या deflate एल्गोरिदम का इस्तेमाल करके डेटा को आसानी से कंप्रेस कर सकता है. इन वजहों से, हमने TAR फ़ाइल फ़ॉर्मैट को चुना है.

TAR एक आसान फ़ॉर्मैट है. हर रिकॉर्ड (फ़ाइल) में 512 बाइट का हेडर होता है. इसके बाद, फ़ाइल का कॉन्टेंट 512 बाइट तक पैड किया जाता है. हमारे काम के लिए, हेडर में सिर्फ़ कुछ काम के या दिलचस्प फ़ील्ड होते हैं. इनमें मुख्य रूप से फ़ाइल का टाइप और नाम शामिल होता है. ये फ़ील्ड, हेडर में तय जगहों पर सेव होते हैं.

TAR फ़ॉर्मैट में हेडर फ़ील्ड, हेडर ब्लॉक में तय जगहों पर और तय साइज़ में सेव किए जाते हैं. उदाहरण के लिए, फ़ाइल में पिछली बार किए गए बदलाव का टाइमस्टैंप, हेडर की शुरुआत से 136 बाइट पर सेव किया जाता है. यह 12 बाइट का होता है. सभी अंकों वाले फ़ील्ड को ASCII फ़ॉर्मैट में सेव किए गए ऑक्टल नंबर के तौर पर एन्कोड किया जाता है. फ़ील्ड को पार्स करने के लिए, हम अपने ऐरे बफ़र से फ़ील्ड निकालते हैं. साथ ही, न्यूमेरिक फ़ील्ड के लिए, हम parseInt() को कॉल करते हैं. साथ ही, ऑक्टल बेस को दिखाने के लिए, दूसरे पैरामीटर को पास करना न भूलें.

टाइप फ़ील्ड, सबसे अहम फ़ील्ड में से एक है. यह एक अंकों वाला ऑक्टल नंबर होता है. इससे हमें पता चलता है कि रिकॉर्ड में किस तरह की फ़ाइल है. हमारे काम के लिए, सिर्फ़ दो तरह के रिकॉर्ड काम के हैं: सामान्य फ़ाइलें ('0') और डायरेक्ट्री ('5'). अगर हम किसी भी तरह की TAR फ़ाइलों का इस्तेमाल कर रहे हैं, तो हो सकता है कि हम सिंबल लिंक ('2') और शायद हार्ड लिंक ('1') का भी इस्तेमाल करें.

हर हेडर के बाद, उस हेडर में बताई गई फ़ाइल का कॉन्टेंट तुरंत दिखता है. हालांकि, डायरेक्ट्री जैसी फ़ाइलों में ऐसा नहीं होता, क्योंकि उनमें कोई कॉन्टेंट नहीं होता. इसके बाद, फ़ाइल के कॉन्टेंट के बाद पैडिंग जोड़ी जाती है, ताकि यह पक्का किया जा सके कि हर हेडर 512-बाइट के बाउंड्री पर शुरू हो. इसलिए, TAR फ़ाइल में किसी फ़ाइल रिकॉर्ड की कुल लंबाई का हिसाब लगाने के लिए, हमें सबसे पहले फ़ाइल का हेडर पढ़ना होगा. इसके बाद, हम हेडर से निकाले गए फ़ाइल कॉन्टेंट की लंबाई में हेडर की लंबाई (512 बाइट) जोड़ देते हैं. आखिर में, हम ऑफ़सेट को 512 बाइट के साथ अलाइन करने के लिए, ज़रूरी पैडिंग बाइट जोड़ते हैं. ऐसा करने के लिए, फ़ाइल की लंबाई को 512 से भाग दें, संख्या का सबसे बड़ा अंक लें, और फिर उसे 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
  };
};

मैंने मौजूदा TAR रीडर के बारे में खोजा और मुझे कुछ मिले, लेकिन उनमें से कोई भी ऐसा नहीं था जिसमें अन्य डिपेंडेंसी न हो या जो हमारे मौजूदा कोडबेस में आसानी से फ़िट हो जाए. इसलिए, मैंने खुद का लेख लिखने का विकल्प चुना. मैंने लोडिंग को बेहतर तरीके से ऑप्टिमाइज़ करने के लिए भी समय निकाला. साथ ही, यह पक्का किया कि डिकोडर, संग्रह में मौजूद बाइनरी और स्ट्रिंग, दोनों तरह के डेटा को आसानी से मैनेज कर सके.

मुझे सबसे पहले यह समस्या हल करनी थी कि XHR अनुरोध से डेटा को कैसे लोड किया जाए. मैंने शुरुआत में "बाइनरी स्ट्रिंग" वाले तरीके का इस्तेमाल किया था. माफ़ करें, बाइनरी स्ट्रिंग को ArrayBuffer जैसे आसानी से इस्तेमाल किए जा सकने वाले बाइनरी फ़ॉर्म में बदलना आसान नहीं है. साथ ही, ऐसा करने में काफ़ी समय भी लगता है. Image ऑब्जेक्ट में बदलना भी उतना ही मुश्किल है.

मैंने XHR अनुरोध से सीधे ArrayBuffer के तौर पर TAR फ़ाइलों को लोड करने का फ़ैसला किया. साथ ही, ArrayBuffer से स्ट्रिंग में बदलने के लिए, एक छोटा फ़ंक्शन जोड़ा. फ़िलहाल, मेरा कोड सिर्फ़ बुनियादी ANSI/8-बिट वर्ण हैंडल करता है. हालांकि, ब्राउज़र में ज़्यादा सुविधाजनक कन्वर्ज़न एपीआई उपलब्ध होने के बाद, इसे ठीक किया जा सकता है.

यह कोड, ArrayBuffer को स्कैन करके रिकॉर्ड हेडर को पार्स करता है. इसमें TAR के सभी काम के हेडर फ़ील्ड (और कुछ काम के नहीं) के साथ-साथ ArrayBuffer में मौजूद फ़ाइल डेटा की जगह और साइज़ की जानकारी भी शामिल होती है. कोड, डेटा को ArrayBuffer व्यू के तौर पर भी निकाल सकता है और उसे रिकॉर्ड हेडर की लिस्ट में सेव कर सकता है. हालांकि, ऐसा करना ज़रूरी नहीं है.

यह कोड, https://github.com/subsonicllc/TarReader.js पर, आसान और अनुमति देने वाले ओपन सोर्स लाइसेंस के तहत मुफ़्त में उपलब्ध है.

FileSystem API

फ़ाइल के कॉन्टेंट को सेव करने और बाद में उसे ऐक्सेस करने के लिए, हमने FileSystem API का इस्तेमाल किया. यह एपीआई काफ़ी नया है, लेकिन इसमें पहले से ही कुछ बेहतरीन दस्तावेज़ मौजूद हैं. इनमें HTML5 Rocks फ़ाइल सिस्टम का लेख भी शामिल है.

FileSystem API के इस्तेमाल से जुड़ी कुछ सावधानियां बरतने की ज़रूरत होती है. एक बात यह है कि यह इवेंट-ड्रिवन इंटरफ़ेस है. इस वजह से, एपीआई को ब्लॉक नहीं किया जाता. यह यूज़र इंटरफ़ेस (यूआई) के लिए बहुत अच्छा है, लेकिन इसका इस्तेमाल करना मुश्किल हो जाता है. WebWorker से FileSystem API का इस्तेमाल करके इस समस्या को कम किया जा सकता है. हालांकि, इसके लिए डाउनलोड करने और अनपैक करने के पूरे सिस्टम को WebWorker में बांटना होगा. यह सबसे अच्छा तरीका भी हो सकता है, लेकिन समय की कमी की वजह से मैंने ऐसा नहीं किया. मुझे तब तक WorkWorkers के बारे में नहीं पता था. इसलिए, मुझे एपीआई के असाइनोक्रोनस इवेंट-ड्रिवन नेचर से निपटना पड़ा.

हमारी ज़रूरतें ज़्यादातर, डायरेक्ट्री स्ट्रक्चर में फ़ाइलें लिखने पर आधारित हैं. इसके लिए, हर फ़ाइल के लिए कई चरणों को पूरा करना होगा. सबसे पहले, हमें फ़ाइल पाथ को एक सूची में बदलना होगा. इसके लिए, पाथ स्ट्रिंग को पाथ सेपरेटर वर्ण (जो हमेशा यूआरएल की तरह फ़ॉरवर्ड-स्लैश होता है) पर बांटें. इसके बाद, हमें आखिरी एलिमेंट को सेव करने के लिए, नतीजों की सूची में मौजूद हर एलिमेंट को दोहराना होगा. साथ ही, ज़रूरत पड़ने पर लोकल फ़ाइल सिस्टम में फिर से डायरेक्ट्री बनानी होगी. इसके बाद, हम फ़ाइल बना सकते हैं और फिर FileWriter बना सकते हैं. आखिर में, फ़ाइल का कॉन्टेंट लिखा जा सकता है.

ध्यान रखने वाली दूसरी अहम बात यह है कि FileSystem API के PERSISTENT स्टोरेज में फ़ाइल का साइज़ कितना होना चाहिए. हमें स्टोरेज को हमेशा सेव रखने की सुविधा चाहिए थी, क्योंकि कुछ समय के लिए सेव किया गया स्टोरेज कभी भी मिटाया जा सकता है. ऐसा तब भी हो सकता है, जब उपयोगकर्ता हमारे गेम को खेल रहा हो और गेम से बाहर की गई फ़ाइल को लोड करने की कोशिश कर रहा हो.

Chrome Web Store को टारगेट करने वाले ऐप्लिकेशन के लिए, ऐप्लिकेशन की मेनिफ़ेस्ट फ़ाइल में unlimitedStorage अनुमति का इस्तेमाल करते समय, स्टोरेज की कोई सीमा नहीं होती. हालांकि, सामान्य वेब ऐप्लिकेशन अब भी स्टोरेज के लिए, कोटा के अनुरोध वाले एक्सपेरिमेंटल इंटरफ़ेस का इस्तेमाल करके अनुरोध कर सकते हैं.

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