個案研究 - SONAR,HTML5 遊戲開發

Sean Middleditch
Sean Middleditch

簡介

去年夏天,我擔任商業 WebGL 遊戲 SONAR 的技術主管。這個專案花了約三個月的時間完成,而且是從頭開始在 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 回呼。遊戲載入畫面只需等待回呼呼叫,即可轉換至下一個畫面。

當然,這個介面還能做更多事情。讀者可以透過練習,瞭解其他值得探討的功能,包括新增進度/百分比支援、新增圖片載入 (使用 Image 類型)、新增 JSON 檔案的自動剖析功能,以及錯誤處理。

這篇文章最重要的功能是 baseurl 欄位,可讓我們輕鬆切換要求檔案的來源。您可以輕鬆設定核心引擎,允許網址中的 ?uselocal 類型查詢參數,從提供遊戲主要 HTML 文件的相同本機網站伺服器 (例如 python -m SimpleHTTPServer) 提供的網址,要求資源,如果未設定參數,則使用快取系統。

包裝資源

資源鏈結載入作業的一個問題是,無法取得所有資料的完整位元組計數。因此,您無法為下載作業建立簡單可靠的進度對話方塊。由於我們會下載所有內容並將其快取,而大型遊戲可能需要相當長的時間,因此提供玩家良好的進度對話方塊相當重要。

解決這個問題最簡單的方法 (同時也帶來其他優點),就是將所有資源檔案封裝成單一套件,然後透過單一 XHR 呼叫下載,這樣就能取得顯示精美進度列所需的進度事件。

建構自訂套件檔案格式並不困難,甚至可以解決一些問題,但需要建立工具來建立套件格式。另一個解決方法是使用現有的封存格式,因為這些格式已有對應的工具,然後編寫在瀏覽器中執行的解碼器。我們不需要壓縮的封存格式,因為 HTTP 已經可以使用 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 要求中,將 TAR 檔案載入為 ArrayBuffer,並新增一個小型便利函式,將區塊從 ArrayBuffer 轉換為字串。目前我的程式碼只處理基本 ANSI/8 位元字元,但只要瀏覽器提供更方便的轉換 API,就能修正這個問題。

程式碼只會掃描 ArrayBuffer,並解析記錄標頭,其中包含所有相關的 TAR 標頭欄位 (以及幾個不太相關的欄位),以及 ArrayBuffer 內的檔案資料位置和大小。程式碼也可以選擇將資料擷取為 ArrayBuffer 檢視畫面,並儲存在傳回的記錄標頭清單中。

您可以前往 https://github.com/subsonicllc/TarReader.js,根據友善且開放的開放原始碼授權,免費取得這段程式碼。

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