SONAR, Phát triển trò chơi HTML5

Sean Middleditch
Sean Middleditch

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 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 nghiệm. 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ừ đĩa CD/DVD cục bộ hoặc từ ổ 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 như vậy 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 những 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 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 riêng là chúng tôi có toàn quyền kiểm soát và linh hoạt theo nhu cầu, đồng thời có thể xây dựng một hệ thống hoàn toàn phù hợp với nhu cầu của mình.

Truy xuất

Trước khi có tính năng lưu vào bộ nhớ đệm tài nguyên, chúng tôi đã có một trình tải tài nguyên được liên kết đơn giản. Hệ thống này cho phép chúng tôi 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 có 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à chuyển sang màn hình tiếp theo chỉ 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 giúp chúng tôi dễ dàng chuyển đổi giữa các tài nguyên được đóng gói và tài nguyên rời (chưa được đó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ự hữu ích 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 của trò chơi.

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

Lệnh gọi lại oncomplete được đí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 đợ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ể làm nhiều việc hơn nữa với giao diện này. Để người đọc có thể thực hành, một số tính năng bổ sung đáng để tìm hiểu là 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 (bằng cách 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à tính năng xử lý lỗi.

Tính năng quan trọng nhất đối với bài viết này là trường baseurl. Trường này 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 tham số truy vấn thuộc loại ?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, trong khi sử dụng hệ thống lưu vào bộ nhớ đệm nếu tham số không được đặt.

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

Cách dễ nhất để khắc phục 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. Nhờ đó, chúng ta có thể nhận được 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 một đị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 sẽ 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 thuật toán gzip hoặc deflate một cách hiệu quả. 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) đều có một tiêu đề 512 byte, theo sau là nội dung tệp được đệm đến 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ác trường này đượ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ữ ở 136 byte kể 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 sẽ 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 sẽ gọi parseInt(), nhớ truyền 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 biết loại tệp mà bản ghi chứa. Hai loại bản ghi duy nhất mà chúng ta quan tâm 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à đườ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 theo sau bởi phần đệ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 độ 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 cộng độ 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 mọi byte đệm cần thiết để điều chỉnh độ lệch thành 512 byte. Bạn có thể dễ dàng thực hiện việc này bằng cách chia độ dài tệp cho 512, lấy giá trị 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 số 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 tự viết. Tôi cũng dành thời gian để tối ưu hoá quá trình tải nhiều nhất có thể và đảm bảo rằng trình giải mã dễ dàng xử lý cả dữ liệu nhị phân và dữ liệu 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à cách 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 đơn giản, cũng như các lượt chuyển đổi như vậy không đặc biệt nhanh. Việc chuyển đổi sang các đố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 vấn đề này có thể được khắc phục khi có API chuyển đổi thuận tiện hơn trong trình duyệt.

Đoạn mã này chỉ cần 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 số 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. Ngoài ra, mã này cũng có thể trích xuất dữ liệu dưới dạng khung hiển thị ArrayBuffer và lưu trữ dữ liệu đó trong danh sách tiêu đề bản ghi được trả về.

Mã này được cung cấp miễn phí theo một giấy phép Nguồn mở thân thiện và cho phép 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 tôi đã 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 cả bài viết về FileSystem của HTML5 Rocks.

API FileSystem cũng có những điểm hạn chế. Một mặt, đây là giao diện dựa trên sự kiệ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 khi sử dụng. Sử dụng FileSystem API từ WebWorker có thể giảm bớt vấn đề này, nhưng việc này sẽ yêu cầu bạn chia 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 hay nhất, nhưng không phải là phương pháp tôi đã chọn 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 dựa trên sự kiện không đồng bộ của API.

Nhu cầu của chúng tôi chủ yếu tập trung vào việc ghi các tệp vào một cấu trúc thư mục. Việc này đòi hỏi bạn phải 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à chuyể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 chia chuỗi đường dẫn trên ký tự phân cách đường dẫn (luôn là dấu gạch chéo, 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 kết quả, ngoại trừ phần tử cuối cùng, bằng cách đệ quy tạo 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 một FileWriter và cuối cùng là ghi nội dung tệp.

Một điều quan trọng thứ hai cần lưu ý là giới hạn kích thước tệp của bộ nhớ PERSISTENT trong 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 tô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, 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 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
  );
}