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

सीन मिडलडिच
शॉन मिडलडिच

शुरुआती जानकारी

पिछली गर्मियों में मैंने, SONAR नाम के एक व्यावसायिक गेम में तकनीकी लीड के तौर पर काम किया था. इस प्रोजेक्ट को पूरा होने में करीब तीन महीने लगे और इसे JavaScript में नए सिरे से बनाया गया. SONAR के विकास के दौरान, हमें HTML5 पानी की कई समस्याओं के इनोवेटिव समाधान खोजने लगे थे. खास तौर पर, हमें एक सामान्य समस्या का हल चाहिए था: जब प्लेयर गेम शुरू करे, तब हम 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 या एल्गोरिदम को डिलेट करके डेटा को कंप्रेस कर सकता है. इन वजहों से, हमने टीएआर फ़ाइल फ़ॉर्मैट पर सेटलमेंट किया.

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

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

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

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

फ़ाइल सिस्टम एपीआई

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

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