Nghiên cứu điển hình – SONAR, Phát triển trò chơi HTML5

[Tên người]
Seam Middleditch

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 3 tháng để hoàn thành và được thực hiện hoàn toàn từ đầu trong 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 các vùng nước HTML5 mới và chưa được kiểm tra. Cụ thể, chúng tôi cần một giải pháp cho một vấn đề có vẻ đơ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 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 máy chơi trò chơi và trò chơi trên máy tính đều tải tài nguyên từ một đĩa CD/DVD cục bộ hoặc từ ổ đĩa cứng. Flash có thể đóng gói tất cả các tài nguyên như một phần của tệp SWF chứa trò chơi và Java có thể thực hiện 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 trò chơi.

HTML5 không cung cấp cho chúng ta các cơ chế này nhưng nó 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 xuống 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 tôi có được tất cả khả năng kiểm soát và sự linh hoạt cần thiết, đồng thời có thể xây dựng 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 lưu tài nguyên vào bộ nhớ đệm, chúng tôi 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 tôi yêu cầu từng tài nguyên theo đường dẫn tương đối, qua đó có thể yêu cầu nhiều tài nguyên hơ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 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 của 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 đóng gói và các tài nguyên rời (chưa đóng gói) được phân phát qua một 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ể nhanh chóng lặp lại cả mã và dữ liệu trò chơi.

Mã sau minh hoạ thiết kế cơ bản của trình tải tài nguyên theo chuỗi, với khả năng xử lý lỗi và xoá mã tải hình ảnh/XHR nâng cao hơn để mọi thứ có thể đọc đượ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 và các đối tượng trò chơi ban đầu. Ví dụ: Đây có thể là các tệp JSON đơn giản. Lệnh gọi lại được dùng cho các tệp này, sau đó kiểm tra dữ liệu đó và có thể thực hiện thêm yêu cầu (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à chất liệu, sau đó lệnh gọi lại cho Material có thể yêu cầu hình ảnh hoạ tiết.

Lệnh gọi lại oncomplete đính kèm với thực thể ResourceLoader chính sẽ chỉ được gọi sau khi tất cả các tài nguyên đều được tải. Màn hình tải trò chơi có thể chỉ cần đợi 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ể thực hiện được nhiều việc hơn với giao diện này. Trong bài tập dành cho người đọc, bạn nên kiểm tra một số tính năng bổ sung như thêm khả năng hỗ trợ tiến trình/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 phân tích cú pháp tệp JSON tự động 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 tôi yêu cầu. Bạn có thể dễ dàng thiết lập công cụ chính để 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 chính máy chủ web cục bộ (như python -m SimpleHTTPServer) phân phát tài liệu HTML chính cho trò chơi trong khi sử dụng hệ thống bộ nhớ đệm nếu tham số không được thiết lập.

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ệ quả của việc này là không có cách nào để tạo hộp thoại tiến trình đơn giản và đáng tin cậy cho tệp tải xuống. Chúng ta sẽ tải tất cả nội dung xuống và lưu vào bộ nhớ đệm. Quá trình này có thể mất nhiều thời gian đối với các trò chơi lớn hơn, nên việc cung cấp cho người chơi hộp thoại tiến trình dễ thấy là khá quan trọng.

Cách khắc phục dễ nhất cho vấn đề này (cũng mang lại cho chúng ta một vài ưu điểm hữu ích 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 xuống bằng một lệnh gọi XHR duy nhất. Việc 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ị một 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í sẽ giải quyết được một vài vấn đề, nhưng bạn sẽ cần phải tạo 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ó đã có công cụ, sau đó cần viết bộ giải mã để chạy trong trình duyệt. Chúng tôi không cần định dạng lưu trữ nén vì HTTP đã có thể nén dữ liệu bằng gzip hoặc giảm bớt các thuật toán. Vì những lý do này, chúng tôi quyết định 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 số trường phù hợp hoặc hữu ích cho mục đích của chúng tôi, chủ yếu là loại 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ữ tại 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ữ 136 byte tính từ đầu tiêu đề và dài 12 byte. Tất cả các trường số được mã hoá dưới dạng số bát phân được lưu trữ theo định dạng ASCII. Sau đó, để 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() đảm bảo truyền tham số thứ hai để biểu thị 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à số bát phân có một chữ số cho chúng ta biết loại tệp có trong bản ghi đó. Hai loại bản ghi duy nhất hữu ích cho chúng ta là các tệp thông thường ('0') và thư mục ('5'). Nếu xử lý các tệp TAR tuỳ ý, chúng ta cũng có thể quan tâm đến đường liên kết tượng trưng ('2') và cả đường liên kết cứng ('1').

Mỗi tiêu đề đứng ngay trước nội dung của tệp mà tiêu đề mô tả (trừ các loại tệp không có nội dung của riêng chúng, chẳng hạn như thư mục). Nội dung tệp sau đó được theo sau là khoảng đệm để đảm bảo rằng mọi tiêu đề đều bắt đầu trên ranh giới 512 byte. Do đó, để tính tổng chiều dài của 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 sẽ 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 khoảng đệm nào cần thiết để căn chỉnh độ lệch thành 512 byte, có thể thực hiện dễ dàng bằng cách chia độ dài tệp cho 512, lấy trần của số, sau đó 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 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ó phần phụ thuộc khác hoặc các trình đọc này 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 tự viết. Tôi cũng dành thời gian để tối ưu hoá việc 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 kho 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ừ yêu cầu XHR. Ban đầu, tôi bắt đầu với phương pháp "chuỗi nhị phân". Thật không may, 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 đơn giản và các quá trình chuyển đổi như vậy cũng đặc biệt nhanh chóng. Việc chuyển đổi sang các đối tượng Image cũng đều gây khó khăn như nhau.

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 điều này có thể được khắc phục sau khi API chuyển đổi thuận tiện hơn có sẵn trong các 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ả trường tiêu đề TAR có liên quan (và một vài trường không liên quan) 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ể tuỳ ý 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ể cung cấp miễn phí mã này theo giấy phép Nguồn mở thân thiện và thoải mái tại https://github.com/subsonicllc/TarReader.js.

API FileSystem

Để thực sự lưu trữ nội dung tệp và truy cập vào chúng sau này, chúng ta đã sử dụng FileSystem API. API này khá mới nhưng đã có một số tài liệu tuyệt vời, bao gồm bài viết tuyệt vời về HTML5 Rocks FileSystem.

FileSystem API luôn có sẵn một số cảnh báo. Một là, đó là giao diện dựa trên sự kiện; cả hai điều này khiến API không chặn, điều này rất tốt cho giao diện người dùng nhưng cũng khiến việc sử dụng trở nên khó khăn. Việc sử dụng FileSystem API từ WebWorker có thể giúp giảm bớt vấn đề này, nhưng điều đó đòi hỏi bạn phải chia toàn bộ hệ thống tải xuống và giải nén thành 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ý tính chất không đồng bộ dựa trên 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 tệp theo cấu trúc thư mục. Việc này yêu cầu 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ể thực hiện việc này dễ dàng bằng cách chia chuỗi đường dẫn trên ký tự phân tách đường dẫn (luôn là dấu gạch chéo lên, chẳng hạn như URL). Sau đó, chúng ta cần lặp lại từng phần tử trong danh sách thu được lưu lần tạo thư mục cuối cùng theo cách đệ quy (nếu cần) trong hệ thống tệp cục bộ. Sau đó, chúng ta có thể tạo tệp, tạo FileWriter và cuối cùng là ghi nội dung tệp.

Điều quan trọng thứ hai cần lưu ý là giới hạn kích thước tệp trong bộ nhớ PERSISTENT của FileSystem API. Chúng tôi muốn có bộ nhớ liên tục 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 của chúng ta ngay trước khi họ cố tải tệp bị loại bỏ.

Đối với các ứng dụng nhắm đến Cửa hàng Chrome trực tuyến, sẽ không có giới hạn về bộ nhớ 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 dung lượng 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
  );
}