Studium przypadku – SONAR, tworzenie gier HTML5

Sean Middleditch
Sean Middleditch

Wstęp

zeszłego lata pracowałem jako kierownik techniczny nad komercyjnej gry WebGL o nazwie SONAR. Realizacja projektu zajęła około 3 miesięcy i została całkowicie od zera w języku JavaScript. Podczas opracowywania SONAR musieliśmy znaleźć innowacyjne rozwiązania wielu problemów w nowych i nieprzetestowanych wodach HTML5. Szczególnie potrzebowaliśmy rozwiązania tego pozornie prostego problemu: jak pobrać i zapisać ponad 70 MB danych gry w pamięci podręcznej, gdy użytkownik uruchamia grę?

Inne platformy mają gotowe rozwiązania tego problemu. Większość konsol i gier komputerowych wczytuje zasoby z lokalnego dysku CD/DVD lub dysku twardego. Flash może spakować wszystkie zasoby jako część pliku SWF z grą, a Java może zrobić to samo z plikami JAR. Cyfrowe platformy dystrybucji, takie jak Steam czy App Store, pobierają i instalują wszystkie zasoby, zanim gracz będzie mógł rozpocząć grę.

HTML5 nie daje nam tych mechanizmów, ale daje nam wszystkie narzędzia potrzebne do stworzenia własnego systemu pobierania zasobów gier. Zaletą stworzenia własnego systemu jest to, że otrzymujemy niezbędną kontrolę i elastyczność oraz możemy stworzyć system dokładnie odpowiadający naszym potrzebom.

Pobieranie

Zanim w ogóle potrzebowaliśmy buforowania zasobów, mieliśmy prosty łańcuchowy program do wczytywania zasobów. System ten umożliwił nam żądania poszczególnych zasobów według ścieżki względnej, co z kolei mogło spowodować zapotrzebowanie na więcej zasobów. Na ekranie wczytywania zaprezentował prosty wskaźnik postępu, który oceniał ilość danych do wczytania. Przeszedł na następny ekran dopiero wtedy, gdy kolejka ładowania zasobów była pusta.

Projekt tego systemu umożliwił nam łatwe przełączanie się między zasobami w pakiecie a zasobami luźnymi (niespakowanymi) udostępnianymi przez lokalny serwer HTTP. Było to kluczowe dla zapewnienia możliwości szybkiego iteracji zarówno w przypadku kodu gry, jak i danych.

W poniższym kodzie pokazano podstawowy projekt naszego połączonego procesu ładowania zasobów w łańcuchu obsługi błędów i usuniętego bardziej zaawansowanego kodu XHR/obrazu, który zapewnia czytelność.

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 jednocześnie dość elastyczne. Początkowy kod gry może żądać plików danych opisujących początkowy poziom gry i obiekty gry. Mogą to być na przykład proste pliki JSON. Wywołanie zwrotne używane dla 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 wywołanie zwrotne materiałów może wtedy zażądać obrazów tekstur.

Wywołanie zwrotne oncomplete dołączone do głównej instancji ResourceLoader zostanie wykonane dopiero po wczytaniu wszystkich zasobów. Ekran wczytywania gry może po prostu zaczekać na jego wywołanie, zanim przejdzie do następnego ekranu.

Oczywiście za pomocą tego interfejsu można zrobić więcej. Warto zapoznać się z kilkoma dodatkowymi funkcjami, które warto przeanalizować, takich jak obsługa postępu/procentu, dodanie wczytywania obrazów (za pomocą typu Obraz), automatyczne analizowanie plików JSON i oczywiście obsługę błędów.

Najważniejszą cechą tego artykułu jest pole baseurl, które umożliwia łatwe przełączanie źródła żądanych plików. Podstawowy mechanizm można łatwo skonfigurować tak, aby parametr zapytania typu ?uselocal w adresie URL wysyłał żądania zasobów z adresu URL udostępnianego przez ten sam lokalny serwer WWW (np. python -m SimpleHTTPServer), który wyświetlał główny dokument HTML gry, a jeśli ten parametr nie był skonfigurowany, korzystał z systemu pamięci podręcznej.

Materiały do opakowań

Jednym z problemów z łańcuchowym wczytywaniem zasobów jest brak możliwości uzyskania pełnej liczby bajtów wszystkich danych. W efekcie nie ma możliwości stworzenia prostego, niezawodnego okna dialogowego postępu pobierania. Będziemy pobierać całą zawartość i zapisywać ją w pamięci podręcznej, a w przypadku większych gier może to zająć dużo czasu, dlatego ważne jest zapewnienie odtwarzacza przyjemnego okna postępu.

Najłatwiejszym rozwiązaniem tego problemu (które daje nam również kilka innych przydatnych korzyści) jest spakowanie wszystkich plików zasobów w jeden pakiet, który zostanie pobrany za pomocą jednego wywołania XHR. Dzięki temu dowiemy się, jakie zdarzenia postępu będziemy potrzebować, aby wyświetlić ładny pasek postępu.

Stworzenie niestandardowego formatu pliku pakietu nie jest trudne i rozwiązuje nawet kilka problemów, ale wymaga stworzenia narzędzia do tworzenia tego formatu. Alternatywnym rozwiązaniem jest wykorzystanie istniejącego formatu archiwum, dla którego istnieją już narzędzia, a następnie napisanie dekodera do uruchomienia w przeglądarce. Nie potrzebujemy skompresowanego archiwum, ponieważ HTTP już jest w stanie skompresować dane za pomocą algorytmów gzip lub deflate. Z tego powodu zdecydowaliśmy się na format pliku TAR.

Format TAR jest stosunkowo prosty. Każdy rekord (plik) ma nagłówek o długości 512 bajtów, po którym następuje wypełnienie zawartości pliku do 512 bajtów. Nagłówek zawiera tylko kilka istotnych lub interesujących pól do naszych celów, a zwłaszcza typ i nazwa pliku, które są przechowywane na 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 12 bajtów. Wszystkie pola liczbowe są zakodowane jako liczby ósemkowe zapisane w formacie ASCII. Aby przeanalizować pola, następnie wyodrębniamy je z bufora tablicy. W przypadku pól liczbowych nazywamy je parseInt(), pamiętając o przekazaniu drugiego parametru w celu wskazania żądanej podstawy ósemkowej.

Jednym z najważniejszych pól jest pole typu. Jest to jednocyfrowa liczba ósemkowa, która informuje nas o typie pliku w rekordzie. Jedynymi 2 interesującymi typami rekordów do naszych celów są zwykłe pliki ('0') i katalogi ('5'). Gdybyśmy mieli do czynienia z dowolnymi plikami TAR, moglibyśmy się też zastanowić nad dowiązaniami symbolicznymi ('2') i prawdopodobnie linkami sztywnymi ('1').

Po każdym nagłówku bezpośrednio poprzedza treść pliku opisanego w nagłówku (z wyjątkiem typów plików, które nie mają własnej zawartości, np. katalogów). Następnie zawartość pliku jest uzupełniana o dopełnienie, aby każdy nagłówek zaczynał się od granicy 512 bajtów. Aby obliczyć całkowitą długość rekordu pliku w pliku TAR, musimy najpierw odczytać jego nagłówek. Następnie dodajemy długość nagłówka (512 bajtów) z długością zawartości pliku wyodrębnionej z nagłówka. Na koniec dodajemy bajty dopełnienia niezbędne, aby przesunięcie wyrównało wartość do 512 bajtów. Można to łatwo zrobić, dzieląc długość pliku przez 512, mnożąc limit liczby 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
  };
};

Wyszukałem(-am) istniejące czytniki TAR i znaleźłem kilka (kilku z nich) nie udostępniało żadnych innych zależności ani takich, które łatwo pasowałyby do naszej obecnej bazy kodu. Z tego powodu zdecydowałam się napisać własną odpowiedź. Poświęciłem też czas na jak najlepszą optymalizację wczytywania i upewnienie się, że dekoder bez problemu obsługuje w archiwum zarówno dane binarne, jak i ciągi znaków.

Jednym z pierwszych problemów, które musiałem rozwiązać, było pobieranie danych z żądania XHR. Pierwotnie zacząłem od podejścia „binarnego ciągu znaków”. Niestety konwersja z ciągów binarnych na łatwiejszą postać binarną, np. ArrayBuffer, nie jest taka prosta, a takie konwersje nie są szczególnie szybkie. Konwertowanie do obiektów Image jest równie trudne.

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 ten problem można naprawić, gdy pojawi się bardziej wygodny interfejs API konwersji w przeglądarkach.

Kod po prostu skanuje analizowane nagłówki rekordu ArrayBuffer, w tym wszystkie odpowiednie pola nagłówka TAR (i kilka mniej istotnych pól), a także lokalizację i rozmiar danych pliku w obrębie ArrayBuffer. Kod może też opcjonalnie wyodrębnić dane jako widok ArrayBuffer i zapisać je na wyświetlonej liście nagłówków rekordów.

Kod ten jest dostępny bezpłatnie na licencji open source https://github.com/subsonicllc/TarReader.js.

Interfejs API FileSystem

Do zapisywania zawartości plików i uzyskiwania do nich dostępu później użyliśmy interfejsu FileSystem API. Interfejs API jest całkiem nowy, ale ma już doskonałą dokumentację, w tym doskonały artykuł HTML5 Rocks FileSystem.

W interfejsie FileSystem API występują pewne ograniczenia. Po pierwsze, jest to interfejs oparty na zdarzeniach, co sprawia, że interfejs API nie blokuje się, co jest świetnym rozwiązaniem dla UI, ale i uciążliwego korzystania z niego. Użycie interfejsu FileSystem API z poziomu WebWorker może rozwiązać ten problem, ale wymagałoby podziału całego systemu pobierania i rozpakowywania na WebWorker. Może to być nawet najlepsza metoda, ale z powodu ograniczeń czasowych (nie znałem jeszcze zasobów WorkWorkers), ale musiałem się zająć asynchroniczną naturą interfejsu API opartą na zdarzeniach.

Nasze potrzeby skupiają się głównie na zapisie plików w strukturze katalogów. W przypadku każdego pliku trzeba wykonać serię czynności. Najpierw musimy otrzymać ścieżkę pliku i przekształcić ją w listę. Można to łatwo zrobić, dzieląc ciąg ścieżki po znaku separatora ścieżki (który zawsze jest ukośnikiem, tak jak w przypadku adresów URL). Następnie musimy iterować każdy element na wynikowej liście z ostatnim zapisem, rekurencyjnie tworząc katalog (w razie potrzeby) w lokalnym systemie plików. Następnie możemy utworzyć plik, utworzyć FileWriter i zapisać jego zawartość.

Drugą ważną rzeczą, o której należy pamiętać, jest limit rozmiaru pliku PERSISTENT miejsca na dane dostępnego w interfejsie FileSystem API. Potrzebowaliśmy pamięci trwałej, bo można ją wyczyścić w dowolnym momencie, również wtedy, gdy użytkownik gra w grę, zanim spróbuje wczytać usunięty plik.

W przypadku aplikacji kierowanych na Chrome Web Store nie obowiązują limity miejsca na dane przy korzystaniu z uprawnienia unlimitedStorage w pliku manifestu aplikacji. 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
  );
}