SONAR, tworzenie gier HTML5

Sean Middleditch
Sean Middleditch

Zeszłego lata byłem kierownikiem technicznym komercyjnej gry WebGL o nazwie SONAR. Projekt został ukończony w około 3 miesiące i został w całości napisany od zera w JavaScript. Podczas tworzenia SONAR musieliśmy znaleźć innowacyjne rozwiązania wielu problemów w nowym i nieprzetestowanym środowisku HTML5. Potrzebowaliśmy w szczególności rozwiązania pozornie prostego problemu: jak pobrać i zapisać w pamięci podręcznej ponad 70 MB danych gry, gdy gracz rozpoczyna rozgrywkę?

Inne platformy mają gotowe rozwiązania tego problemu. Większość konsol i gier na PC wczytuje zasoby z lokalnej płyty CD/DVD lub dysku twardego. Flash może spakować wszystkie zasoby w ramach pliku SWF zawierającego grę, a Java może zrobić to samo w przypadku plików JAR. Platformy dystrybucji cyfrowej, takie jak Steam czy App Store, dbają o to, aby wszystkie zasoby zostały pobrane i zainstalowane, zanim gracz będzie mógł rozpocząć grę.

HTML5 nie udostępnia takich mechanizmów, ale daje nam wszystkie narzędzia potrzebne do zbudowania własnego systemu pobierania zasobów gry. Zaletą stworzenia własnego systemu jest to, że mamy pełną kontrolę i elastyczność oraz możemy zbudować system, który dokładnie odpowiada naszym potrzebom.

Pobieranie

Zanim wprowadziliśmy buforowanie zasobów, mieliśmy prosty łańcuchowy moduł wczytywania zasobów. Ten system umożliwiał nam wysyłanie żądań dotyczących poszczególnych zasobów za pomocą ścieżki względnej, która z kolei mogła wysyłać żądania dotyczące kolejnych zasobów. Na ekranie wczytywania wyświetlaliśmy prosty wskaźnik postępu, który informował, ile danych trzeba jeszcze wczytać. Przechodziliśmy do następnego ekranu dopiero po opróżnieniu kolejki modułu wczytywania zasobów.

Konstrukcja tego systemu umożliwiła nam łatwe przełączanie się między spakowanymi zasobami a zasobami luźnymi (niespakowanymi) udostępnianymi przez lokalny serwer HTTP, co było bardzo pomocne w szybkim iteracyjnym ulepszaniu zarówno kodu, jak i danych gry.

Poniższy kod ilustruje podstawową strukturę naszego połączonego narzędzia do wczytywania zasobów. Usunęliśmy z niego kod obsługi błędów i bardziej zaawansowany kod wczytywania obrazów/XHR, aby był bardziej czytelny.

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

Korzystanie z tego interfejsu jest dość proste, ale też bardzo elastyczne. Początkowy kod gry może wysyłać żądania dotyczące niektórych plików danych opisujących początkowy poziom gry i obiekty w grze. Mogą to być na przykład proste pliki JSON. Funkcja zwrotna używana w przypadku tych plików sprawdza dane i może wysyłać dodatkowe żądania (żądania połączone) dotyczące zależności. Plik definicji obiektów gry może zawierać listę modeli i materiałów, a wywołanie zwrotne dla materiałów może następnie wysyłać żądania dotyczące obrazów tekstur.

Wywołanie zwrotne oncomplete dołączone do głównej instancji ResourceLoader zostanie wywołane dopiero po wczytaniu wszystkich zasobów. Ekran wczytywania gry może po prostu czekać na wywołanie tego wywołania zwrotnego przed przejściem do następnego ekranu.

Oczywiście ten interfejs umożliwia znacznie więcej. Jako ćwiczenia dla czytelnika warto zbadać kilka dodatkowych funkcji, takich jak dodanie obsługi postępu/procentu, dodanie wczytywania obrazów (za pomocą typu Image), dodanie automatycznego analizowania plików JSON i oczywiście obsługa błędów.

Najważniejszym elementem w tym artykule jest pole baseurl, które umożliwia łatwe przełączanie źródła plików, o które prosimy. Łatwo jest skonfigurować główny silnik tak, aby zezwalał na parametr zapytania typu ?uselocal w adresie URL w celu wysyłania żądań zasobów z adresu URL obsługiwanego przez ten sam lokalny serwer WWW (np. python -m SimpleHTTPServer), który obsługuje główny dokument HTML gry, a jednocześnie korzystał z systemu pamięci podręcznej, jeśli parametr nie jest ustawiony.

Zasoby dotyczące opakowań

Jednym z problemów z łańcuchowym wczytywaniem zasobów jest to, że nie ma możliwości uzyskania pełnej liczby bajtów wszystkich danych. W rezultacie nie można utworzyć prostego, wiarygodnego okna postępu pobierania. Pobieranie i buforowanie wszystkich treści może zająć sporo czasu w przypadku większych gier, dlatego ważne jest, aby wyświetlać graczowi odpowiednie okno postępu.

Najprostszym rozwiązaniem tego problemu (które daje nam też kilka innych zalet) jest spakowanie wszystkich plików zasobów w jeden pakiet, który pobierzemy za pomocą jednego wywołania XHR. Dzięki temu uzyskamy zdarzenia postępu potrzebne 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 uruchamiania w przeglądarce. Nie potrzebujemy skompresowanego formatu archiwum, ponieważ HTTP może już kompresować dane za pomocą algorytmów gzip lub deflate. Z tych powodów zdecydowaliśmy się na format pliku TAR.

TAR to stosunkowo prosty format. Każdy rekord (plik) ma nagłówek o rozmiarze 512 bajtów, a po nim następuje zawartość pliku uzupełniona do 512 bajtów. Nagłówek zawiera tylko kilka istotnych lub interesujących nas pól, głównie typ i nazwę 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łych rozmiarach w bloku nagłówka. Na przykład sygnatura czasowa ostatniej modyfikacji pliku jest przechowywana w odległości 136 bajtów od początku nagłówka i ma długość 12 bajtów. Wszystkie pola numeryczne są zakodowane jako liczby ósemkowe przechowywane w formacie ASCII. Aby przeanalizować pola, wyodrębniamy je z bufora tablicy, a w przypadku pól liczbowych wywołujemy funkcję parseInt(), pamiętając o przekazaniu drugiego parametru, aby wskazać żądaną podstawę ósemkową.

Jednym z najważniejszych pól jest pole typu. Jest to jednocyfrowa liczba ósemkowa, która informuje nas o typie pliku, jaki zawiera rekord. Dla naszych celów interesujące są tylko 2 typy rekordów: zwykłe pliki ('0') i katalogi ('5'). W przypadku dowolnych plików TAR moglibyśmy też uwzględnić linki symboliczne ('2') i ewentualnie linki twarde ('1').

Po każdym nagłówku następuje bezpośrednio zawartość pliku opisanego przez nagłówek (z wyjątkiem typów plików, które nie mają własnej zawartości, np. katalogów). Po zawartości pliku następuje wypełnienie, aby każdy nagłówek zaczynał się na granicy 512 bajtów. Aby obliczyć łączną długość rekordu pliku w pliku TAR, musimy najpierw odczytać nagłówek pliku. Następnie dodajemy długość nagłówka (512 bajtów) do długości zawartości pliku wyodrębnionej z nagłówka. Na koniec dodajemy niezbędne bajty dopełnienia, aby przesunięcie było równe 512 bajtów. Można to łatwo zrobić, dzieląc długość pliku przez 512, zaokrąglając wynik w górę i mnożąc go 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
  };
};

Przejrzałem dostępne czytniki plików TAR i znalazłem kilka, ale żaden z nich nie był niezależny ani nie pasował do naszego obecnego kodu. Z tego powodu postanowiłem napisać własną. Poświęciłem też czas na jak najlepszą optymalizację ładowania i upewniłem się, że dekoder z łatwością obsługuje zarówno dane binarne, jak i ciągi znaków w archiwum.

Jednym z pierwszych problemów, które musiałem rozwiązać, było wczytywanie danych z żądania XHR. Początkowo używałem podejścia „ciągu binarnego”. Niestety przekształcenie ciągów binarnych w bardziej użyteczne formy binarne, takie jak ArrayBuffer, nie jest proste ani szybkie. Konwertowanie na obiekty Image jest równie uciążliwe.

Zdecydowałem się wczytać 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 API do konwersji.

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

Kod jest dostępny bezpłatnie na podstawie przyjaznej, liberalnej licencji open source na stronie https://github.com/subsonicllc/TarReader.js.

FileSystem API

Do przechowywania zawartości plików i późniejszego uzyskiwania do nich dostępu użyliśmy interfejsu FileSystem API. Ten interfejs API jest dość nowy, ale ma już świetną dokumentację, w tym doskonały artykuł o interfejsie FileSystem na stronie HTML5 Rocks.

Interfejs FileSystem API ma pewne ograniczenia. Po pierwsze, jest to interfejs oparty na zdarzeniach. Dzięki temu interfejs API nie blokuje działania, co jest świetne w przypadku interfejsu użytkownika, ale utrudnia korzystanie z niego. Korzystanie z interfejsu FileSystem API w WebWorkerze może rozwiązać ten problem, ale wymagałoby to podzielenia całego systemu pobierania i rozpakowywania na WebWorkera. To może być nawet najlepsze podejście, ale ze względu na ograniczenia czasowe (nie znałem jeszcze WorkWorkers) nie zdecydowałem się na nie i musiałem poradzić sobie z asynchronicznym charakterem interfejsu API opartym na zdarzeniach.

Nasze potrzeby dotyczą głównie zapisywania plików w strukturze katalogów. Wymaga to wykonania szeregu czynności w przypadku każdego pliku. Najpierw musimy przekształcić ścieżkę pliku w listę. Można to łatwo zrobić, dzieląc ciąg ścieżki za pomocą znaku separatora ścieżki (który jest zawsze ukośnikiem, tak jak w przypadku adresów URL). Następnie musimy przejść przez każdy element na liście wynikowej z wyjątkiem ostatniego, rekursywnie tworząc katalog (w razie potrzeby) w lokalnym systemie plików. Następnie możemy utworzyć plik, a potem utworzyć FileWriter i wreszcie zapisać zawartość pliku.

Drugą ważną kwestią jest limit rozmiaru plików w PERSISTENT pamięci interfejsu FileSystem API. Zależało nam na trwałym miejscu na dane, ponieważ tymczasowe miejsce na dane może zostać wyczyszczone w dowolnym momencie, nawet gdy użytkownik gra w naszą grę i próbuje wczytać usunięty plik.

W przypadku aplikacji przeznaczonych na Chrome Web Store nie ma limitów miejsca na dane, gdy w pliku manifestu aplikacji używane jest uprawnienie unlimitedStorage. Zwykłe aplikacje internetowe mogą jednak nadal 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
  );
}