去年夏天,我擔任商業 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
);
}