Giới thiệu
Mùa hè năm ngoái, tôi làm trưởng nhóm kỹ thuật cho một trò chơi WebGL thương mại có tên là SONAR. Dự án này mất khoảng ba tháng để hoàn thành và được thực hiện hoàn toàn từ đầu bằng JavaScript. Trong quá trình phát triển SONAR, chúng tôi đã phải tìm ra các giải pháp sáng tạo cho một số vấn đề trong môi trường HTML5 mới và chưa được kiểm thử. Cụ thể, chúng tôi cần một giải pháp cho một vấn đề tưởng chừng như đơn giản: làm cách nào để tải xuống và lưu vào bộ nhớ đệm hơn 70 MB dữ liệu trò chơi khi người chơi bắt đầu trò chơi?
Các nền tảng khác có sẵn giải pháp cho vấn đề này. Hầu hết các trò chơi trên máy chơi trò chơi và máy tính đều tải tài nguyên từ ổ CD/DVD cục bộ hoặc từ ổ đĩa cứng. Flash có thể đóng gói tất cả tài nguyên trong tệp SWF chứa trò chơi và Java cũng có thể làm tương tự với tệp JAR. Các nền tảng phân phối kỹ thuật số như Steam hoặc App Store đảm bảo rằng tất cả tài nguyên đều được tải xuống và cài đặt trước khi người chơi có thể bắt đầu chơi trò chơi.
HTML5 không cung cấp các cơ chế này, nhưng cung cấp cho chúng ta tất cả các công cụ cần thiết để xây dựng hệ thống tải tài nguyên trò chơi của riêng mình. Ưu điểm của việc xây dựng hệ thống của riêng mình là chúng ta có toàn quyền kiểm soát và linh hoạt như mong muốn, đồng thời có thể xây dựng một hệ thống phù hợp chính xác với nhu cầu của mình.
Truy xuất
Trước khi có bộ nhớ đệm tài nguyên, chúng ta có một trình tải tài nguyên theo chuỗi đơn giản. Hệ thống này cho phép chúng ta yêu cầu các tài nguyên riêng lẻ theo đường dẫn tương đối, từ đó có thể yêu cầu thêm tài nguyên. Màn hình tải của chúng tôi hiển thị một đồng hồ đo tiến trình đơn giản để đo lường lượng dữ liệu cần tải thêm và chỉ chuyển sang màn hình tiếp theo sau khi hàng đợi trình tải tài nguyên trống.
Thiết kế của hệ thống này cho phép chúng tôi dễ dàng chuyển đổi giữa các tài nguyên được đóng gói và các tài nguyên rời (chưa đóng gói) được phân phát qua máy chủ HTTP cục bộ. Điều này thực sự đóng vai trò quan trọng trong việc đảm bảo rằng chúng tôi có thể lặp lại nhanh chóng cả mã trò chơi và dữ liệu.
Mã sau đây minh hoạ thiết kế cơ bản của trình tải tài nguyên theo chuỗi, với việc xoá mã xử lý lỗi và mã tải hình ảnh/XHR nâng cao hơn để dễ đọc.
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();
};
Cách sử dụng giao diện này khá đơn giản nhưng cũng khá linh hoạt. Mã trò chơi ban đầu có thể yêu cầu một số tệp dữ liệu mô tả cấp độ trò chơi ban đầu và các đối tượng trò chơi. Ví dụ: đây có thể là các tệp JSON đơn giản. Sau đó, lệnh gọi lại dùng cho các tệp này sẽ kiểm tra dữ liệu đó và có thể tạo các yêu cầu bổ sung (yêu cầu theo chuỗi) cho các phần phụ thuộc. Tệp định nghĩa đối tượng trò chơi có thể liệt kê các mô hình và tài liệu, sau đó lệnh gọi lại cho tài liệu có thể yêu cầu hình ảnh kết cấu.
Lệnh gọi lại oncomplete
đính kèm vào thực thể ResourceLoader
chính sẽ chỉ được gọi sau khi tất cả tài nguyên được tải. Màn hình tải trò chơi chỉ cần chờ lệnh gọi lại đó được gọi trước khi chuyển sang màn hình tiếp theo.
Tất nhiên, bạn có thể làm được nhiều việc hơn nữa với giao diện này. Để làm bài tập cho người đọc, bạn nên tìm hiểu thêm một số tính năng khác như thêm tính năng hỗ trợ tiến trình/tỷ lệ phần trăm, thêm tính năng tải hình ảnh (sử dụng loại Hình ảnh), thêm tính năng tự động phân tích cú pháp tệp JSON và tất nhiên là xử lý lỗi.
Tính năng quan trọng nhất của bài viết này là trường baseurl, cho phép chúng ta dễ dàng chuyển đổi nguồn của các tệp mà chúng ta yêu cầu. Bạn có thể dễ dàng thiết lập công cụ cốt lõi để cho phép loại tham số truy vấn ?uselocal
trong URL yêu cầu tài nguyên từ một URL do cùng một máy chủ Web cục bộ phân phát (chẳng hạn như python -m SimpleHTTPServer
) đã phân phát tài liệu HTML chính cho trò chơi, đồng thời sử dụng hệ thống bộ nhớ đệm nếu không đặt tham số.
Tài nguyên đóng gói
Một vấn đề với việc tải tài nguyên theo chuỗi là không có cách nào để có được số byte đầy đủ của tất cả dữ liệu. Hậu quả của việc này là không có cách nào để tạo một hộp thoại tiến trình đơn giản và đáng tin cậy cho nội dung tải xuống. Vì chúng ta sẽ tải tất cả nội dung xuống và lưu vào bộ nhớ đệm, nên việc này có thể mất khá nhiều thời gian đối với các trò chơi lớn hơn. Vì vậy, việc cung cấp cho người chơi một hộp thoại tiến trình đẹp mắt là rất quan trọng.
Cách khắc phục dễ dàng nhất cho vấn đề này (cũng mang lại cho chúng ta một số lợi thế khác) là đóng gói tất cả các tệp tài nguyên vào một gói duy nhất. Chúng ta sẽ tải gói này xuống bằng một lệnh gọi XHR duy nhất. Lệnh gọi này sẽ cung cấp cho chúng ta các sự kiện tiến trình cần thiết để hiển thị thanh tiến trình đẹp mắt.
Việc tạo định dạng tệp gói tuỳ chỉnh không quá khó và thậm chí có thể giải quyết một số vấn đề, nhưng bạn cần tạo một công cụ để tạo định dạng gói. Một giải pháp thay thế là sử dụng định dạng lưu trữ hiện có mà các công cụ đã tồn tại, sau đó cần viết một bộ giải mã để chạy trong trình duyệt. Chúng ta không cần định dạng lưu trữ nén vì HTTP đã có thể nén dữ liệu bằng cách sử dụng thuật toán gzip hoặc deflate. Vì những lý do này, chúng tôi đã chọn định dạng tệp TAR.
TAR là một định dạng tương đối đơn giản. Mỗi bản ghi (tệp) có một tiêu đề 512 byte, theo sau là nội dung tệp được thêm vào 512 byte. Tiêu đề chỉ có một vài trường liên quan hoặc thú vị cho mục đích của chúng ta, chủ yếu là loại tệp và tên tệp, được lưu trữ ở các vị trí cố định trong tiêu đề.
Các trường tiêu đề ở định dạng TAR được lưu trữ ở các vị trí cố định với kích thước cố định trong khối tiêu đề. Ví dụ: dấu thời gian sửa đổi gần đây nhất của tệp được lưu trữ ở vị trí 136 byte từ đầu tiêu đề và có độ dài 12 byte. Tất cả các trường số đều được mã hoá dưới dạng số bát phân được lưu trữ ở định dạng ASCII. Để phân tích cú pháp các trường, chúng ta trích xuất các trường từ vùng đệm mảng và đối với các trường số, chúng ta gọi parseInt()
, nhớ truyền vào tham số thứ hai để cho biết cơ sở bát phân mong muốn.
Một trong những trường quan trọng nhất là trường loại. Đây là một số bát phân có một chữ số cho chúng ta biết loại tệp mà bản ghi chứa. Chỉ có hai loại bản ghi thú vị cho mục đích của chúng ta là tệp thông thường ('0'
) và thư mục ('5'
). Nếu đang xử lý các tệp TAR tuỳ ý, chúng ta cũng có thể quan tâm đến các đường liên kết tượng trưng ('2'
) và có thể là các đường liên kết cứng ('1'
).
Ngay sau mỗi tiêu đề là nội dung của tệp do tiêu đề mô tả (ngoại trừ các loại tệp không có nội dung riêng, chẳng hạn như thư mục). Sau đó, nội dung tệp sẽ được thêm vào để đảm bảo rằng mọi tiêu đề đều bắt đầu ở ranh giới 512 byte. Do đó, để tính tổng chiều dài của một bản ghi tệp trong tệp TAR, trước tiên, chúng ta phải đọc tiêu đề của tệp. Sau đó, chúng ta thêm độ dài của tiêu đề (512 byte) với độ dài của nội dung tệp được trích xuất từ tiêu đề. Cuối cùng, chúng ta thêm bất kỳ byte đệm nào cần thiết để căn chỉnh độ dời đến 512 byte. Bạn có thể dễ dàng thực hiện việc này bằng cách chia chiều dài tệp cho 512, lấy số trần của số đó rồi nhân với 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
};
};
Tôi đã tìm kiếm các trình đọc TAR hiện có và tìm thấy một vài trình đọc, nhưng không có trình đọc nào không có các phần phụ thuộc khác hoặc dễ dàng phù hợp với cơ sở mã hiện có của chúng tôi. Vì lý do này, tôi đã chọn viết riêng. Tôi cũng dành thời gian để tối ưu hoá quá trình tải tốt nhất có thể và đảm bảo rằng bộ giải mã dễ dàng xử lý cả dữ liệu nhị phân và chuỗi trong tệp lưu trữ.
Một trong những vấn đề đầu tiên tôi phải giải quyết là làm cách nào để thực sự tải dữ liệu từ một yêu cầu XHR. Ban đầu, tôi bắt đầu bằng phương pháp "chuỗi nhị phân". Rất tiếc, việc chuyển đổi từ chuỗi nhị phân sang các dạng nhị phân dễ sử dụng hơn như ArrayBuffer
không hề đơn giản và các lượt chuyển đổi như vậy cũng không nhanh chóng. Việc chuyển đổi sang đối tượng Image
cũng khó khăn không kém.
Tôi quyết định tải các tệp TAR dưới dạng ArrayBuffer
trực tiếp từ yêu cầu XHR và thêm một hàm tiện lợi nhỏ để chuyển đổi các đoạn từ ArrayBuffer
thành một chuỗi. Hiện tại, mã của tôi chỉ xử lý các ký tự ANSI/8 bit cơ bản, nhưng bạn có thể khắc phục vấn đề này sau khi có API chuyển đổi thuận tiện hơn trong trình duyệt.
Mã này chỉ quét qua ArrayBuffer
để phân tích cú pháp các tiêu đề bản ghi, bao gồm tất cả các trường tiêu đề TAR có liên quan (và một vài trường không liên quan lắm) cũng như vị trí và kích thước của dữ liệu tệp trong ArrayBuffer
. Mã này cũng có thể trích xuất dữ liệu dưới dạng thành phần hiển thị ArrayBuffer
và lưu trữ dữ liệu đó trong danh sách tiêu đề bản ghi được trả về.
Bạn có thể sử dụng mã này miễn phí theo giấy phép Nguồn mở thân thiện, dễ chấp nhận tại https://github.com/subsonicllc/TarReader.js.
FileSystem API
Để thực sự lưu trữ nội dung tệp và truy cập vào nội dung đó sau này, chúng ta đã sử dụng API FileSystem. API này khá mới nhưng đã có một số tài liệu tuyệt vời, bao gồm cả bài viết tuyệt vời về FileSystem của HTML5 Rocks.
API FileSystem không phải là không có lưu ý. Một điều là đây là giao diện do sự kiện điều khiển; điều này vừa giúp API không bị chặn, rất phù hợp với giao diện người dùng, nhưng cũng gây khó khăn cho việc sử dụng. Bạn có thể sử dụng API FileSystem từ một WebWorker để giảm thiểu vấn đề này, nhưng điều đó sẽ yêu cầu tách toàn bộ hệ thống tải xuống và giải nén thành một WebWorker. Đó thậm chí có thể là phương pháp tốt nhất, nhưng không phải là phương pháp tôi đã sử dụng do hạn chế về thời gian (tôi chưa quen với WorkWorkers), vì vậy, tôi đã phải xử lý bản chất không đồng bộ do sự kiện của API.
Nhu cầu của chúng ta chủ yếu tập trung vào việc ghi các tệp vào cấu trúc thư mục. Bạn cần thực hiện một loạt các bước cho mỗi tệp. Trước tiên, chúng ta cần lấy đường dẫn tệp và biến đường dẫn đó thành một danh sách. Bạn có thể dễ dàng thực hiện việc này bằng cách tách chuỗi đường dẫn theo ký tự phân cách đường dẫn (luôn là dấu gạch chéo lên, như URL). Sau đó, chúng ta cần lặp lại từng phần tử trong danh sách kết quả, ngoại trừ phần tử cuối cùng, tạo đệ quy một thư mục (nếu cần) trong hệ thống tệp cục bộ. Sau đó, chúng ta có thể tạo tệp, rồi tạo FileWriter
và cuối cùng là ghi nội dung tệp.
Điều quan trọng thứ hai cần xem xét là giới hạn kích thước tệp của bộ nhớ PERSISTENT
của FileSystem API. Chúng tôi muốn sử dụng bộ nhớ cố định vì bộ nhớ tạm thời có thể bị xoá bất cứ lúc nào, kể cả khi người dùng đang chơi trò chơi ngay trước khi trò chơi cố gắng tải tệp bị xoá.
Đối với các ứng dụng nhắm đến Cửa hàng Chrome trực tuyến, không có giới hạn bộ nhớ nào khi sử dụng quyền unlimitedStorage
trong tệp kê khai của ứng dụng. Tuy nhiên, các ứng dụng web thông thường vẫn có thể yêu cầu không gian bằng giao diện yêu cầu hạn mức thử nghiệm.
function allocateStorage(space_in_bytes, success, error) {
webkitStorageInfo.requestQuota(
webkitStorageInfo.PERSISTENT,
space_in_bytes,
function() {
webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);
},
error
);
}