個案研究 - HTML5 遊戲開發 SONAR

尚恩米爾迪奇
Sean Middleditch

引言

去年夏天,我擔任 SONAR 商用 WebGL 技術領域的技術主管。在 JavaScript 中,這項專案花了約三個月的時間,並完全從頭完成。在開發 SONAR 的過程中,我們必須找出創新解決方案來解決各種創新和未經測試的 HTML5 問題。具體來說,我們需要解決一個看似簡單的問題:玩家開始玩遊戲時,要如何下載及快取超過 70 MB 的遊戲資料?

其他平台已針對這個問題提供現成解決方案。大多數的遊戲主機和電腦遊戲都會從本機 CD/DVD 或硬碟載入資源。Flash 可將所有資源封裝為包含遊戲的 SWF 檔案,而 Java 也能使用 JAR 檔案進行同樣的操作。Steam 或 App Store 等數位發行平台會確保所有資源都已下載並安裝,然後玩家就可以開始玩遊戲。

HTML5 並不提供這些機制,但提供了建立遊戲資源下載系統所需的一切工具。打造專屬系統的缺點是,我們能獲得所需的一切掌控權和彈性,可以打造完全符合需求的系統。

擷取

在開始進行資源快取之前,我們其實採用了簡單的鏈結資源載入器。這個系統可讓我們依據相對路徑要求個別資源,進而要求更多資源。我們的載入畫面會顯示簡單的進度計,用於測量需載入多少資料,並且只在資源載入器佇列清空時才會轉換至下一個畫面。

這個系統的設計讓我們可以輕鬆在封裝資源和透過本機 HTTP 伺服器提供的鬆散資源之間切換,這是確保我們能夠快速疊代遊戲程式碼和資料的行為。

下列程式碼展示了鏈結資源載入器的基本設計,包含錯誤處理功能及移除更進階的 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 呼叫進行下載,進而提供顯示良好進度列所需的進度事件。

建立自訂套件檔案格式並不容易,甚至可以解決幾個問題,但您必須建立用於建立軟體包格式的工具。替代的方法是使用既有的封存格式,然後需要為瀏覽器執行解碼器。HTTP 可以使用 gzip 壓縮資料,或是延遲演算法,因此不需要壓縮封存格式。因此,我們決定使用 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 要求將 TAR 檔案載入為 ArrayBuffer,並新增小型的便利函式,以便將區塊從 ArrayBuffer 轉換為字串。我的程式碼目前只處理基本的 ANSI/8 位元字元,但只要在瀏覽器中推出更便利的轉換 API,就可以修正這個問題。

程式碼會直接掃描 ArrayBuffer 剖析記錄標頭,其中包括所有相關的 TAR 標題欄位 (以及一些不相關的欄位),以及 ArrayBuffer 中檔案資料的位置和大小。程式碼也可以選擇性地將資料擷取為 ArrayBuffer 檢視畫面,並將該資料儲存在傳回的記錄標頭清單中。

我們可在 https://github.com/subsonicllc/TarReader.js 中公開且符合許可的開放原始碼授權,免費取得程式碼。

檔案系統 API

為了實際儲存檔案內容並在之後存取,我們使用 FileSystem API。這個 API 剛推出不久,但已有很棒的說明文件,包括很棒的 HTML5 Rocks FileSystem 文章

FileSystem API 不需要特別注意。有一點是,它是事件導向的介面;這既會導致 API 非阻塞,這對 UI 有益,但也會令人難以使用。透過 WebWorker 使用 FileSystem API 可以緩解這個問題,但這麼做必須將整個下載和解壓縮系統分割為 WebWorker。這也可能是最合適的做法,但因為時間限制 (我還不熟悉 WorkWorkers),所以我必須處理這個 API 以非同步事件為導向的本質。

我們的需求主要著重於將檔案寫入目錄結構。需要為每個檔案執行一系列步驟。首先,我們必須擷取檔案路徑並將其轉換成清單,方法是在路徑分隔符字元 (一律是正斜線,例如網址) 中分割路徑字串,就能輕鬆完成。接著,我們需要疊代結果清單中的每個元素,為最後一個儲存項目,在本機檔案系統中以遞迴方式建立目錄。接著,我們可以建立檔案,然後建立 FileWriter,最後寫出檔案內容。

第二個重點是 FileSystem API PERSISTENT 儲存空間的檔案大小限制。我們想要使用永久儲存空間,因為臨時儲存空間可隨時清除,就算使用者仍在遊戲中,也無法嘗試載入已撤銷的檔案。

以 Chrome 線上應用程式商店為目標的應用程式,在應用程式的資訊清單檔案中使用 unlimitedStorage 權限時,則沒有任何儲存空間限制。不過,一般網頁應用程式仍可透過實驗性配額要求介面要求空間。

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