Studium przypadku – SONAR, tworzenie gier HTML5

Sean Middleditch
Sean Middleditch

Wprowadzenie

Zeszłego lata pracowałem jako kierownik techniczny nad komercyjną grą WebGL o nazwie SONAR. Projekt trwał około 3 miesięcy i został napisany od podstaw w języku JavaScript. Podczas tworzenia SONAR musieliśmy znaleźć innowacyjne rozwiązania dla wielu problemów związanych z nową i niesprawdzoną technologią HTML5. Potrzebowaliśmy rozwiązania pozornie prostego problemu: jak pobrać i przechowywać w pamięci podręcznej ponad 70 MB danych gry, gdy gracz rozpoczyna grę?

Inne platformy mają gotowe rozwiązania tego problemu. Większość gier na konsole i komputery wczytuje zasoby z lokalnego dysku CD/DVD lub twardego dysku. Flash może spakować wszystkie zasoby jako część pliku SWF zawierającego grę, a Java może zrobić to samo z plikami JAR. Platformy dystrybucji cyfrowej, takie jak Steam czy App Store, zapewniają, że wszystkie zasoby są pobierane i instalowane jeszcze przed uruchomieniem gry.

HTML5 nie zapewnia tych mechanizmów, ale daje nam wszystkie narzędzia potrzebne do tworzenia własnego systemu pobierania zasobów gry. Zaletą tworzenia własnego systemu jest to, że mamy pełną kontrolę i elastyczność, a także możemy stworzyć system, który dokładnie odpowiada naszym potrzebom.

Pobieranie

Zanim wprowadziliśmy buforowanie zasobów, mieliśmy prosty łańcuchowy ładownik zasobów. Ten system umożliwiał nam żądanie poszczególnych zasobów za pomocą ścieżki względnej, która z kolei mogła żądać kolejnych zasobów. Na ekranie wczytywania wyświetlał się prosty licznik postępu, który wskazywał, ile jeszcze danych trzeba wczytać. Przejście do następnego ekranu następowało dopiero po opróżnieniu kolejki wczytywania zasobów.

Dzięki temu systemowi mogliśmy łatwo przełączać się między zapakowanymi zasobami a niespakowanymi zasobami (rozpakowanymi) udostępnianymi przez lokalny serwer HTTP. To bardzo pomogło nam w szybkim ulepszaniu kodu i danych gry.

Poniższy kod przedstawia podstawową strukturę naszego łańcuchowego ładownika zasobów. Aby zachować czytelność kodu, usunięto z niego obsługę błędów i bardziej zaawansowany kod ładowania XHR/obrazu.

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

Interfejs jest prosty, ale też elastyczny. Początkowy kod gry może wymagać niektórych plików danych opisujących początkowy poziom i obiekty gry. Mogą to być na przykład proste pliki JSON. Następnie wywołanie zwrotne używane w przypadku tych plików sprawdza te dane i może wysyłać dodatkowe żądania (łańcuchowe żądania) dotyczące zależności. Plik definicji obiektów gry może zawierać listę modeli i materiałów, a zwrot wywołania dla materiałów może następnie poprosić o obrazy tekstur.

Funkcja oncomplete dołączona do głównej instancji ResourceLoader zostanie wywołana dopiero po załadowaniu wszystkich zasobów. Ekran wczytywania gry może po prostu czekać na wywołanie tej funkcji z powrotem przed przejściem do następnego ekranu.

Oczywiście można zrobić z nim znacznie więcej. Jako ćwiczenie dla czytelnika warto zapoznać się z kilkoma dodatkowymi funkcjami, takimi jak dodawanie obsługi postępu/procentu, dodawanie wczytywania obrazu (za pomocą typu Image), dodawanie automatycznej analizy plików JSON i oczywiście obsługa błędów.

Najważniejszą funkcją w tym artykule jest pole baseurl, które pozwala nam łatwo przełączać źródło plików, których żądamy. Łatwo skonfigurować silnik podstawowy, aby zezwalał na parametr zapytania typu ?uselocal w adresie URL, który umożliwia żądanie zasobów z adresu URL serwowanego przez ten sam lokalny serwer WWW (np. python -m SimpleHTTPServer), który serwował główny dokument HTML gry, korzystając z systemu pamięci podręcznej, jeśli parametr nie jest ustawiony.

Materiały dotyczące opakowania

Jednym z problemów związanych z łańcuchowym wczytywaniem zasobów jest to, że nie ma możliwości uzyskania pełnego zliczania bajtów wszystkich danych. W konsekwencji nie ma możliwości stworzenia prostego, niezawodnego okna postępu pobierania. Ponieważ będziemy pobierać wszystkie treści i przechowywać je w pamięci podręcznej, co może zająć sporo czasu w przypadku większych gier, ważne jest, aby wyświetlić graczowi ładny komunikat.

Najprostszym rozwiązaniem tego problemu (które przynosi też kilka innych zalet) jest spakowanie wszystkich plików zasobów do jednego pakietu, który pobieramy za pomocą pojedynczego wywołania XHR. Dzięki temu mamy dostęp do zdarzeń postępu, których potrzebujemy do wyświetlenia paska postępu.

Utworzenie niestandardowego formatu pliku pakietu nie jest bardzo trudne i rozwiązałoby nawet kilka problemów, ale wymagałoby utworzenia narzędzia do tworzenia formatu pakietu. Alternatywnym rozwiązaniem jest użycie istniejącego formatu archiwum, dla którego istnieją już narzędzia, a następnie napisanie dekodera do uruchomienia w przeglądarce. Nie potrzebujemy skompresowanego formatu archiwum, ponieważ HTTP może już skompresować dane za pomocą algorytmów gzip lub deflate. Z tych powodów zdecydowaliśmy się na format pliku TAR.

Plik TAR jest stosunkowo prostym formatem. Każdy rekord (plik) ma nagłówek o długości 512 bajtów, po którym następuje zawartość pliku uzupełniona do 512 bajtów. W nagłówku jest tylko kilka pól, które są dla nas istotne lub interesujące, głównie typ i nazwa pliku, które są przechowywane w stałych pozycjach w nagłówku.

Pola nagłówka w formacie TAR są przechowywane w stałych lokalizacjach o stałej wielkości w bloku nagłówka. Na przykład sygnatura czasu ostatniej modyfikacji pliku jest przechowywana w 136 bajtach od początku nagłówka i ma długość 12 bajtów. Wszystkie pola liczbowe są kodowane jako liczby oktalne przechowywane w formacie ASCII. Aby przeanalizować pola, wyodrębniamy je z bufora tablic, a w przypadku pól liczbowych wywołujemy funkcję parseInt(), podając jako drugi parametr żądaną podstawę oktalną.

Jednym z najważniejszych pól jest pole „Typ”. Jest to 1-cyfrowa liczba oktalna, która określa typ pliku. W naszym przypadku interesują nas tylko dwa typy rekordów: zwykłe pliki ('0') i katalogi ('5'). W przypadku dowolnych plików TAR warto też zwrócić uwagę na linki symboliczne ('2') i możliwe linki stałe ('1').

Każdy nagłówek jest bezpośrednio poprzedzony zawartością pliku opisanego przez nagłówek (z wyjątkiem typów plików, które nie mają własnej zawartości, takich jak katalogi). Zawartość pliku jest następnie uzupełniana, aby każdy nagłówek zaczynał się od granicy 512 bajtów. Aby obliczyć łączną długość rekordu pliku w pliku TAR, najpierw musimy odczytać jego nagłówek. Następnie dodajemy długość nagłówka (512 bajtów) do długości treści pliku wyodrębnionej z nagłówka. Na koniec dodajemy bajty wypełniające, które są niezbędne do wyrównania przesunięcia do 512 bajtów. Można to łatwo zrobić, dzieląc długość pliku przez 512, zaokrąglając wynik w górę, a następnie mnożąc przez 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
  };
};

Szukałem już istniejących czytników TAR i znalazłem kilka, ale żaden z nich nie miał innych zależności ani nie pasował do naszej dotychczasowej bazy kodu. Z tego powodu zdecydowałem się napisać własny. Poświęciłem też trochę czasu na optymalizację ładowania i upewnienie się, że dekoder bez problemu poradzi sobie z danymi binarnymi i łańcuchowymi w archiwum.

Jednym z pierwszych problemów, które musiałem rozwiązać, było uzyskanie danych wczytanych z żądania XHR. Początkowo zastosowałem podejście oparte na „binarnym ciągu znaków”. Niestety konwertowanie ciągów binarnych na bardziej przydatne formy binarne, np. ArrayBuffer, nie jest proste ani szybkie. Konwertowanie na obiekty Image jest równie trudne.

Postanowiłem wczytywać pliki TAR jako ArrayBuffer bezpośrednio z żądania XHR i dodać małą funkcję ułatwiającą konwersję fragmentów z ArrayBuffer na ciąg znaków. Obecnie mój kod obsługuje tylko podstawowe znaki ANSI/8-bitowe, ale można to naprawić, gdy w przeglądarkach będzie dostępny wygodniejszy interfejs konwersji API.

Kod po prostu skanuje plik ArrayBuffer, analizując nagłówki rekordów, które obejmują wszystkie odpowiednie pola nagłówka TAR (oraz kilka mniej istotnych) oraz lokalizację i rozmiar danych pliku w pliku ArrayBuffer. Kod może też opcjonalnie wyodrębnić dane jako widok ArrayBuffer i przechowywać je na liście nagłówków zwróconych rekordów.

Kod jest dostępny na zasadach przyjaznej, permisywnej licencji Open Source pod adresem https://github.com/subsonicllc/TarReader.js.

FileSystem API

Do przechowywania zawartości plików i dostępu do nich w przyszłości użyliśmy interfejsu FileSystem API. Interfejs API jest dość nowy, ale już teraz ma świetną dokumentację, w tym znakomity artykuł HTML5 Rocks FileSystem.

Interfejs FileSystem API ma też pewne ograniczenia. Po pierwsze, jest to interfejs oparty na zdarzeniach, co sprawia, że interfejs API nie blokuje się, co jest świetne dla interfejsu użytkownika, ale też utrudnia jego używanie. Używanie interfejsu FileSystem API z WebWorkera może złagodzić ten problem, ale wymaga to podzielenia całego systemu pobierania i rozpakowywania na WebWorkera. To może być nawet najlepsze podejście, ale nie zdecydowałem się na nie ze względu na ograniczenia czasowe (nie miałem jeszcze doświadczenia z WorkWorkers), więc musiałem poradzić sobie z asyncjonalnym charakterem interfejsu API sterowanego zdarzeniami.

Nasze potrzeby dotyczą głównie zapisywania plików w strukturze katalogów. W tym celu musisz wykonać serię czynności w przypadku każdego pliku. Najpierw musimy przekształcić ścieżkę pliku w listę, co można łatwo zrobić, dzieląc ciąg ścieżki za pomocą znaku rozdzielnika ścieżki (który zawsze jest ukośnikiem, jak w przypadku adresów URL). Następnie musimy przejrzeć każdy element na powstałej liście, z wyjątkiem ostatniego, i rekursywnie utworzyć katalog (w razie potrzeby) w lokalnym systemie plików. Następnie możemy utworzyć plik, a potem FileWriter, a na koniec zapisać zawartość pliku.

Drugą ważną rzeczą, którą należy wziąć pod uwagę, jest limit rozmiaru pliku w miejscu do przechowywania PERSISTENT interfejsu FileSystem API. Chcieliśmy użyć trwałego miejsca na dane, ponieważ tymczasowe miejsce na dane może zostać usunięte w dowolnym momencie, w tym w trakcie gry, tuż przed próbą załadowania wyrzuconego pliku.

W przypadku aplikacji przeznaczonych na potrzeby Chrome Web Store nie ma limitów miejsca na dane, jeśli w pliku manifestu aplikacji jest używane uprawnienie unlimitedStorage. Jednak zwykłe aplikacje internetowe nadal mogą prosić o miejsce za pomocą eksperymentalnego interfejsu żądania limitu.

function allocateStorage(space_in_bytes, success, error) {
  webkitStorageInfo.requestQuota(
    webkitStorageInfo.PERSISTENT,
    space_in_bytes,
    function() {
      webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);      
    },
    error
  );
}