Studium przypadku – Bouncy Mouse

Wprowadzenie

Mysz z bouncing

Po opublikowaniu gry Bouncy Mouse na iOS i Androida pod koniec zeszłego roku nauczyłem się kilku bardzo ważnych rzeczy. Najważniejszym z nich było to, że wejście na ugruntowany rynek jest trudne. Na bardzo nasyconym rynku iPhone’ów zdobycie popularności było bardzo trudne. Na mniej nasyconym rynku Androida Marketplace łatwiej było osiągnąć postępy, ale i tak nie było to łatwe. W związku z tym dostrzegłem ciekawą możliwość w Chrome Web Store. Sklep internetowy nie jest w żaden sposób pusty, ale jego katalog wysokiej jakości gier w formacie HTML5 dopiero zaczyna się rozrastać. Dla nowego dewelopera aplikacji oznacza to, że łatwiej jest mu wejść na listy rankingowe i zyskać widoczność. Mając to na uwadze, postanowiłem przenieść Bouncy Mouse na HTML5, aby udostępnić najnowszą wersję gry nowej grupie użytkowników. W tym opracowaniu omówię ogólny proces przenoszenia gry Bouncy Mouse na HTML5, a potem zagłębię się w 3 obszarach, które okazały się interesujące: dźwięk, wydajność i zarabianie.

Przenoszenie gry w C++ do HTML5

Gra Bouncy Mouse jest obecnie dostępna na Androida(C++), iOS (C++), Windows Phone 7 (C#) i Chrome (Javascript). Czasami pojawia się pytanie: jak napisać grę, którą można łatwo przenieść na wiele platform? Mam wrażenie, że ludzie mają nadzieję na jakąś magiczną kulę, która pozwoli im osiągnąć ten poziom przenośności bez korzystania z portu na rękę. Niestety nie jestem pewien, czy takie rozwiązanie istnieje (najbliżej temu jest prawdopodobnie framework PlayN lub silnik Unity, ale żaden z nich nie spełnia wszystkich moich wymagań). Moje podejście było w istocie portowaniem ręcznym. Najpierw napisałem wersję na iOS/Androida w C++, a potem przeportowałem ten kod na każdą nową platformę. Może się to wydawać dużo pracy, ale wersje WP7 i Chrome zajęły nie więcej niż 2 tygodnie. Pytanie brzmi więc, czy można w jakimś stopniu ułatwić przenoszenie kodu źródłowego? Aby to zrobić, wykonałem kilka czynności:

Mały zbiór kodu

Chociaż może się to wydawać oczywiste, jest to główny powód, dla którego udało mi się tak szybko przenieść grę. Kod klienta Bouncy Mouse składa się z zaledwie około 7000 wierszy kodu C++. 7000 wierszy kodu to niemało, ale jest to liczba na tyle mała, że można ją opanować. Wersje kodu klienta w językach C# i JavaScript miały mniej więcej taki sam rozmiar. Utrzymywanie niewielkiej bazy kodu polegało głównie na 2 kluczowych metodach: nie pisać zbędnego kodu i robić jak najwięcej w ramach wstępnego przetwarzania (poza czasem wykonywania). Niepisywanie nadmiaru kodu może wydawać się oczywiste, ale zawsze zmagam się z tym w głowie. Często mam ochotę napisać pomocniczą klasę lub funkcję dla wszystkiego, co może być uwzględnione w pomocniku. Jeśli jednak nie planujesz używać pomocnika wielokrotnie, zwykle powoduje on tylko zaśmiecanie kodu. W przypadku Bouncy Mouse starałam się nigdy nie pisać funkcji pomocniczej, chyba że miałam zamiar jej użyć co najmniej 3 razy. Gdy pisałem pomocniczą klasę, starałem się, aby była przejrzysta, przenośna i można było jej używać w przyszłych projektach. Z drugiej strony, podczas pisania kodu tylko dla Bouncy Mouse, przy niskiej szansie na ponowne użycie, skupiłem się na jak najprostszym i najszybszym wykonaniu zadania, nawet jeśli nie było to „najładniejsze” rozwiązanie. Drugim, ważniejszym sposobem na utrzymanie niewielkiego rozmiaru kodu źródłowego było przeniesienie jak największej ilości operacji na etapy wstępnego przetwarzania. Jeśli możesz przenieść zadanie wykonywane w czasie działania na zadanie wstępnego przetwarzania, nie tylko przyspieszysz działanie gry, ale też nie będziesz musiał przenosić kodu na każdą nową platformę. Na przykład początkowo dane geometrii poziomu były przechowywane w nieprzetworzonym formacie, a właściwe bufory wierzchołków OpenGL/WebGL były tworzone w czasie działania. Wymagało to trochę konfiguracji i kilkuset linii kodu czasu wykonywania. Później przeniosłem ten kod do etapu wstępnego przetwarzania, zapisując w czasie kompilacji w pełni zapakowane bufory wierzchołków OpenGL/WebGL. Rzeczywista ilość kodu była mniej więcej taka sama, ale te kilkaset linii zostało przeniesionych do etapu wstępnego przetwarzania, co oznacza, że nie musiałem ich przenosić na żadne nowe platformy. W grze Bouncy Mouse jest mnóstwo przykładów tego typu. Możliwości różnią się w zależności od gry, ale warto zwrócić uwagę na wszystko, co nie musi się wydarzyć w czasie działania.

Nie wprowadzaj niepotrzebnych zależności

Kolejnym powodem, dla którego Bouncy Mouse jest łatwy do przeportowania, jest to, że nie ma on prawie żadnych zależności. Poniższy wykres zawiera podsumowanie najważniejszych zależności biblioteki Bouncy Mouse na poszczególnych platformach:

Android iOS HTML5 WP7
Grafika OpenGL ES OpenGL ES WebGL XNA
Dźwięk OpenSL ES OpenAL Web Audio XNA
Fizyka Box2D Box2D Box2D.js Box2D.xna

To w zasadzie wszystko. Nie użyto żadnych dużych bibliotek innych firm poza Box2D, która jest przenośna na wszystkich platformach. W przypadku grafiki zarówno WebGL, jak i XNA są mapowane prawie 1:1 z OpenGL, więc nie było to dużym problemem. Różnice dotyczyły tylko bibliotek dźwięków. Kod dźwiękowy w Bouncy Mouse jest jednak niewielki (około setki wierszy kodu platformowego), więc nie stanowił dużego problemu. Dzięki temu, że Bouncy Mouse nie zawiera dużych bibliotek, które nie są przenośne, logika kodu środowiska wykonawczego może być prawie taka sama w różnych wersjach (pomimo zmiany języka). Pozwala nam to też uniknąć zablokowania się w nieprzenośnym łańcuchu narzędzi. Pytanie, które dostałem, dotyczyło tego, czy kodowanie bezpośrednio w OpenGL/WebGL powoduje zwiększenie złożoności w porównaniu z korzystaniem z biblioteki takiej jak Cocos2D czy Unity (są też dostępne narzędzia pomocnicze do WebGL). W przeciwieństwie do tego, co sądzisz, Większość gier na telefony komórkowe lub w formacie HTML5 (przynajmniej takie jak Bouncy Mouse) jest bardzo prosta. W większości przypadków gra po prostu rysuje kilka sprite’ów i być może jakąś teksturowaną geometrię. Łączna liczba wierszy kodu związanego z OpenGL w Bouncy Mouse wynosi prawdopodobnie mniej niż 1000. Będę zaskoczony, jeśli korzystanie z biblioteki pomocniczej rzeczywiście zmniejszy tę liczbę. Nawet gdyby udało mi się zmniejszyć tę liczbę o połowę, musiałbym poświęcić sporo czasu na zapoznanie się z nowymi bibliotekami i narzędziami tylko po to, aby zaoszczędzić 500 wierszy kodu. Poza tym nie udało mi się jeszcze znaleźć biblioteki pomocniczej, która działałaby na wszystkich interesujących mnie platformach, więc dodanie takiej zależności znacznie zmniejszyłoby przenośność. Gdybym pisał grę 3D, która wymagałaby mapowania światła, dynamicznego LOD, animacji z teksturą i tak dalej, moja odpowiedź z pewnością by się zmieniła. W tym przypadku musiałbym wymyślać koło, próbując ręcznie zaprogramować cały silnik w OpenGL. Chodzi o to, że większość gier na urządzenia mobilne i w HTML5 nie należy (jeszcze) do tej kategorii, więc nie ma potrzeby komplikowania sytuacji, dopóki nie będzie to konieczne.

Nie lekceważ podobieństw między językami

Ostatni trik, który zaoszczędzył mi sporo czasu podczas przenoszenia kodu C++ na nowy język, to odkrycie, że większość kodu jest prawie identyczna w każdym języku. Niektóre kluczowe elementy mogą się zmienić, ale jest ich znacznie mniej niż elementów, które nie ulegają zmianie. W przypadku wielu funkcji przejście z C++ na JavaScript wymagało po prostu uruchomienia kilku zastępowań wyrażeń regularnych w kodzie C++.

Wnioski dotyczące przenoszenia

To w zasadzie wszystko, co dotyczy procesu przenoszenia. W kolejnych sekcjach omówię kilka problemów związanych z HTML5, ale najważniejsze jest to, że jeśli Twój kod będzie prosty, przenoszenie nie będzie stanowiło problemu.

Audio

Jednym z obszarów, który sprawił mi (i wszystkim innym) trochę kłopotów, był dźwięk. Na iOS i Androida jest dostępnych kilka solidnych bibliotek audio (OpenSL, OpenAL), ale w świecie HTML5 sytuacja wyglądała gorzej. Format HTML5 Audio jest dostępny, ale w przypadku gier ma pewne problemy, które uniemożliwiają jego używanie. Nawet w najnowszych przeglądarkach często spotykałem się z dziwnym zachowaniem. Chrome, na przykład, ma ograniczenie liczby jednoczesnych elementów audio (source), które możesz utworzyć. Co więcej, nawet gdy dźwięk był odtwarzany, czasami był niewyjaśnione zniekształcony. Ogólnie byłem trochę zaniepokojony. Poszukiwania w internecie wykazały, że prawie wszyscy mają ten sam problem. Początkowo zdecydowałem się na interfejs API o nazwie SoundManager2. Ten interfejs API używa dźwięku w formacie HTML5, jeśli jest dostępny, a w trudnych sytuacjach korzysta z Flasha. To rozwiązanie działało, ale było pełne błędów i nieprzewidywalne (mniej niż czyste dźwięk HTML5). Tydzień po premierze rozmawiałem z pomocnymi pracownikami Google, którzy polecili mi interfejs Web Audio API w Webkit. Początkowo rozważałem użycie tego interfejsu API, ale zrezygnowałem z niego, ponieważ wydawał mi się zbyt skomplikowany (dla mnie). Chciałem tylko odtworzyć kilka dźwięków: w przypadku dźwięku w formacie HTML5 wystarczy kilka linii kodu JavaScript. Jednak po krótkim zapoznaniu się z Web Audio uderzyło mnie to, że specyfikacja jest ogromna (70 stron), w internecie jest niewiele przykładów (co jest typowe dla nowego interfejsu API), a w specyfikacji nie ma funkcji „odtwórz”, „wstrzymaj” ani „zatrzymaj”. Po zapewnieniach Google, że moje obawy są nieuzasadnione, znów zagłębiłem się w interfejsie API. Po zapoznaniu się z kilkoma przykładami i przeprowadzeniu dodatkowych badań okazało się, że Google miał rację – ten interfejs API może zdecydowanie zaspokoić moje potrzeby, a co więcej, nie ma błędów, które występują w przypadku innych interfejsów API. Szczególnie przydatny jest artykuł Rozpoczęcie pracy z Web Audio API, który jest świetnym źródłem informacji, jeśli chcesz lepiej poznać ten interfejs API. Moim prawdziwym problemem jest to, że nawet po zrozumieniu i użyciu interfejsu API nadal wydaje mi się, że nie jest on przeznaczony do „tylko odtwarzania kilku dźwięków”. Aby rozwiązać ten problem, napisałem małą pomocniczą klasę, która pozwala mi używać interfejsu API w wybrany przeze mnie sposób – odtwarzać, wstrzymywać, zatrzymywać i wysyłać zapytania dotyczące stanu dźwięku. Nazwałem tę pomocniczą klasę AudioClip. Pełny kod źródłowy jest dostępny na GitHubie na licencji Apache 2.0. Poniżej omówię szczegóły klasy. Najpierw kilka informacji o Web Audio API:

Wykresy Web Audio

Pierwszą rzeczą, która sprawia, że interfejs Web Audio API jest bardziej złożony (i bardziej wydajny) niż element audio HTML5, jest jego zdolność do przetwarzania i miksowania dźwięku przed jego wyświetleniem użytkownikowi. Odtwarzanie dźwięku wiąże się z wykorzystaniem wykresu, co sprawia, że nawet w prostych scenariuszach wszystko staje się nieco bardziej skomplikowane. Aby zilustrować możliwości interfejsu Web Audio API, zobacz ten wykres:

Podstawowy wykres dźwięku w przeglądarce
Podstawowy wykres dźwięku w internecie

Chociaż powyższy przykład pokazuje możliwości interfejsu Web Audio API, w moim przypadku nie musiałem korzystać z większości z nich. Chciałem tylko odtworzyć dźwięk. Wymaga to wykresu, ale jest on bardzo prosty.

Wykresy mogą być proste

Pierwszą rzeczą, która sprawia, że interfejs Web Audio API jest bardziej złożony (i bardziej wydajny) niż element audio HTML5, jest jego zdolność do przetwarzania i miksowania dźwięku przed jego wyświetleniem użytkownikowi. Odtwarzanie dźwięku wiąże się z wykorzystaniem wykresu, co sprawia, że nawet w prostych scenariuszach wszystko staje się nieco bardziej skomplikowane. Aby zilustrować możliwości interfejsu Web Audio API, zobacz ten wykres:

Trivial Web Audio Graph
Prosty graf dźwięku w internecie

Prosty wykres pokazany powyżej może zawierać wszystko, co jest potrzebne do odtwarzania, wstrzymywania i zatrzymywania dźwięku.

Nie przejmuj się jednak wykresem.

Chociaż zrozumienie wykresu jest przydatne, nie chcę się z nim za każdym razem zmagać, gdy odtwarzam dźwięk. Dlatego napisałem prostą klasę opakowującą „AudioClip”. Ta klasa zarządza tym grafem wewnętrznie, ale udostępnia znacznie prostszy interfejs API dla użytkowników.

AudioClip
AudioClip

Ta klasa to nic innego jak wykres Web Audio i niektóre pomocnicze stany, ale pozwala mi używać znacznie prostszego kodu niż w przypadku tworzenia wykresu Web Audio do odtwarzania każdego dźwięku.

// At startup time
var sound = new AudioClip("ping.wav");

// Later
sound.play();

Szczegóły implementacji

Szybko przyjrzyjmy się kodom klasy pomocniczej: Konstruktor – konstruktor obsługuje wczytywanie danych dźwięku za pomocą XHR. Chociaż nie jest to pokazane (aby przykład był prostszy), jako węzeł źródłowy można też użyć elementu Audio HTML5. Jest to szczególnie przydatne w przypadku dużych próbek. Pamiętaj, że interfejs Web Audio API wymaga, aby pobierać te dane jako „arraybuffer”. Po otrzymaniu danych tworzymy z nich bufor Web Audio (dekodując je z pierwotnego formatu do formatu PCM w czasie działania).

/**
* Create a new AudioClip object from a source URL. This object can be played,
* paused, stopped, and resumed, like the HTML5 Audio element.
*
* @constructor
* @param {DOMString} src
* @param {boolean=} opt_autoplay
* @param {boolean=} opt_loop
*/
AudioClip = function(src, opt_autoplay, opt_loop) {
// At construction time, the AudioClip is not playing (stopped),
// and has no offset recorded.
this.playing_ = false;
this.startTime_ = 0;
this.loop_ = opt_loop ? true : false;

// State to handle pause/resume, and some of the intricacies of looping.
this.resetTimout_ = null;
this.pauseTime_ = 0;

// Create an XHR to load the audio data.
var request = new XMLHttpRequest();
request.open("GET", src, true);
request.responseType = "arraybuffer";

var sfx = this;
request.onload = function() {
// When audio data is ready, we create a WebAudio buffer from the data.
// Using decodeAudioData allows for async audio loading, which is useful
// when loading longer audio tracks (music).
AudioClip.context.decodeAudioData(request.response, function(buffer) {
    sfx.buffer_ = buffer;
    
    if (opt_autoplay) {
    sfx.play();
    }
});
}

request.send();
}

Odtwarzanie – odtwarzanie dźwięku wymaga wykonania 2 czynności: skonfigurowania wykresu odtwarzania i wywołania wersji „noteOn” w źródle wykresu. Źródło można odtworzyć tylko raz, więc za każdym razem musimy ponownie utworzyć źródło/graf. Większość złożoności tej funkcji wynika z wymagań potrzebnych do wznowienia wstrzymanego klipu (this.pauseTime_ > 0). Aby wznowić odtwarzanie wstrzymanego klipu, używamy noteGrainOn, co pozwala odtwarzać podregion bufora. Niestety w tym scenariuszu noteGrainOn nie działa w pożądany sposób (będzie odtwarzać w pętli podregion, a nie cały bufor). Dlatego musimy obejść ten problem, odtwarzając pozostałą część klipu za pomocą noteGrainOn, a potem ponownie uruchamiając klip od początku z włączonym powtarzaniem.

/**
* Recreates the audio graph. Each source can only be played once, so
* we must recreate the source each time we want to play.
* @return {BufferSource}
* @param {boolean=} loop
*/
AudioClip.prototype.createGraph = function(loop) {
var source = AudioClip.context.createBufferSource();
source.buffer = this.buffer_;
source.connect(AudioClip.context.destination);

// Looping is handled by the Web Audio API.
source.loop = loop;

return source;
}

/**
* Plays the given AudioClip. Clips played in this manner can be stopped
* or paused/resumed.
*/
AudioClip.prototype.play = function() {
if (this.buffer_ && !this.isPlaying()) {
// Record the start time so we know how long we've been playing.
this.startTime_ = AudioClip.context.currentTime;
this.playing_ = true;
this.resetTimeout_ = null;

// If the clip is paused, we need to resume it.
if (this.pauseTime_ > 0) {
    // We are resuming a clip, so it's current playback time is not correctly
    // indicated by startTime_. Correct this by subtracting pauseTime_.
    this.startTime_ -= this.pauseTime_;
    var remainingTime = this.buffer_.duration - this.pauseTime_;

    if (this.loop_) {
    // If the clip is paused and looping, we need to resume the clip
    // with looping disabled. Once the clip has finished, we will re-start
    // the clip from the beginning with looping enabled
    this.source_ = this.createGraph(false);
    this.source_.noteGrainOn(0, this.pauseTime_, remainingTime)

    // Handle restarting the playback once the resumed clip has completed.
    // *Note that setTimeout is not the ideal method to use here. A better 
    // option would be to handle timing in a more predictable manner,
    // such as tying the update to the game loop.
    var clip = this;
    this.resetTimeout_ = setTimeout(function() { clip.stop(); clip.play() },
                                    remainingTime * 1000);
    } else {
    // Paused non-looping case, just create the graph and play the sub-
    // region using noteGrainOn.
    this.source_ = this.createGraph(this.loop_);
    this.source_.noteGrainOn(0, this.pauseTime_, remainingTime);
    }

    this.pauseTime_ = 0;
} else {
    // Normal case, just creat the graph and play.
    this.source_ = this.createGraph(this.loop_);
    this.source_.noteOn(0);
}
}
}

Odtwarzanie jako efekt dźwiękowy – funkcja odtwarzania powyżej nie pozwala na odtwarzanie klipu audio wielokrotnie z nakładaniem (drugie odtwarzanie jest możliwe tylko wtedy, gdy klip został zakończony lub zatrzymany). Czasami gra chce odtworzyć dźwięk wiele razy bez oczekiwania na zakończenie odtwarzania (np. zbieranie monet w grze). Aby to umożliwić, klasa AudioClip ma metodę playAsSFX(). Ponieważ odtwarzanie może odbywać się jednocześnie, odtwarzanie z playAsSFX() nie jest powiązane 1:1 z AudioClip. W związku z tym nie można zatrzymać odtwarzania, wstrzymać go ani zapytać o stan. Powtarzanie jest też wyłączone, ponieważ nie ma możliwości zatrzymania dźwięku odtwarzanego w ten sposób.

/**
* Plays the given AudioClip as a sound effect. Sound Effects cannot be stopped
* or paused/resumed, but can be played multiple times with overlap.
* Additionally, sound effects cannot be looped, as there is no way to stop
* them. This method of playback is best suited to very short, one-off sounds.
*/
AudioClip.prototype.playAsSFX = function() {
if (this.buffer_) {
var source = this.createGraph(false);
source.noteOn(0);
}
}

Zatrzymanie, wstrzymanie i stan zapytania – pozostałe funkcje są dość proste i nie wymagają wielu wyjaśnień:

/**
* Stops an AudioClip , resetting its seek position to 0.
*/
AudioClip.prototype.stop = function() {
if (this.playing_) {
this.source_.noteOff(0);
this.playing_ = false;
this.startTime_ = 0;
this.pauseTime_ = 0;
if (this.resetTimeout_ != null) {
    clearTimeout(this.resetTimeout_);
}
}
}

/**
* Pauses an AudioClip. The offset into the stream is recorded to allow the
* clip to be resumed later.
*/
AudioClip.prototype.pause = function() {
if (this.playing_) {
this.source_.noteOff(0);
this.playing_ = false;
this.pauseTime_ = AudioClip.context.currentTime - this.startTime_;
this.pauseTime_ = this.pauseTime_ % this.buffer_.duration;
this.startTime_ = 0;
if (this.resetTimeout_ != null) {
    clearTimeout(this.resetTimeout_);
}
}
}

/**
* Indicates whether the sound is playing.
* @return {boolean}
*/
AudioClip.prototype.isPlaying = function() {
var playTime = this.pauseTime_ +
            (AudioClip.context.currentTime - this.startTime_);

return this.playing_ && (this.loop_ || (playTime < this.buffer_.duration));
}

Podsumowanie audio

Mam nadzieję, że ta klasa pomocnicza okaże się przydatna dla deweloperów, którzy mają te same problemy z dźwiękiem co ja. Poza tym ta klasa wydaje się być dobrym miejscem na rozpoczęcie, nawet jeśli chcesz dodać niektóre z bardziej zaawansowanych funkcji interfejsu Web Audio API. W obu przypadkach to rozwiązanie spełniało wymagania Bouncy Mouse i pozwalało na stworzenie prawdziwej gry HTML5 bez żadnych ograniczeń.

Wyniki

Kolejnym obszarem, który w przypadku portowania kodu JavaScriptu mnie niepokoił, była wydajność. Po zakończeniu portowania wersji 1 okazało się, że na moim komputerze z procesorem czterordzeniowym wszystko działa dobrze. Niestety na netbookach i Chromebookach nie było to już tak dobre. W tym przypadku profilator Chrome uratował mnie, pokazując dokładnie, na co szły moje zasoby czasowe. Z moich doświadczeń wynika, że profilowanie jest bardzo ważne przed wprowadzeniem optymalizacji. Spodziewałem/się, że spowolnienie jest spowodowane przez fizykę Box2D lub kod renderowania, ale większość czasu zajęła mi funkcja Matrix.clone(). Z uwagi na charakter mojej gry, w której dominują obliczenia, wiedziałem, że często tworzyłem i klonowałem macierze, ale nie spodziewałem się, że to właśnie będzie wąskim gardłem. Ostatecznie okazało się, że bardzo prosta zmiana pozwoliła zmniejszyć zużycie procesora przez grę ponad 3-krotnie, z 6–7% na moim komputerze do 2%. Być może jest to powszechna wiedza wśród programistów JavaScript, ale jako programista C++ byłem zaskoczony tym problemem, więc opiszę go nieco dokładniej. Pierwotna klasa macierzy była macierzy 3 x 3: tablicą 3 elementów, z których każdy zawiera tablicę 3 elementów. Oznaczało to, że gdy nadszedł czas klonowania macierzy, musiałem utworzyć 4 nowe tablice. Jedyną zmianą, jaką musiałem wprowadzić, było przeniesienie tych danych do pojedynczego tablicę o 9 elementach i odpowiednie zmodyfikowanie obliczeń. Ta jedna zmiana była w pełni odpowiedzialna za 3-krotne zmniejszenie obciążenia procesora. Po tej zmianie wydajność była akceptowalna na wszystkich urządzeniach testowych.

Więcej optymalizacji

Chociaż wydajność była zadowalająca, wystąpiły jeszcze drobne problemy. Po dokładniejszym przeanalizowaniu profilu okazało się, że problem wynika z zbierania elementów zbędących w Javascript. Aplikacja działała z częstotliwością 60 FPS, co oznacza, że na wyświetlenie każdej klatki było tylko 16 ms. Niestety, gdy na wolniejszym komputerze włączała się funkcja usuwania elementów, czasami zajmowała ona około 10 ms. Powodowało to zacinanie co kilka sekund, ponieważ gra wymagała prawie pełnych 16 ms na wyświetlenie pełnego obrazu. Aby lepiej zrozumieć, dlaczego generuję tak dużo danych nieużytecznych, użyłem profilowania stosu w Chrome. Niestety okazało się, że zdecydowana większość śmieci (ponad 70%) była generowana przez Box2D. Usuwanie śmieci w Javascript jest trudne, a przepisywanie Box2D nie wchodziło w rachubę, więc zdałem sobie sprawę, że wpadłem w bardzo trudną sytuację. Na szczęście znałem jeden z najstarszych trików: gdy nie można uzyskać 60 FPS, należy ustawić 30 FPS. Ogólnie wiadomo, że płynne 30 FPS jest znacznie lepsze niż 60 FPS z występującymi szarpnięciami. W rzeczywistości nie otrzymaliśmy jeszcze ani jednej skargi ani komentarza dotyczącej tego, że gra działa z częstotliwością 30 FPS (trudno to stwierdzić, chyba że porówna się obie wersje obok siebie). Te dodatkowe 16 ms na klatkę oznaczały, że nawet w przypadku brzydkiego usuwania zbędnych danych miałem jeszcze sporo czasu na renderowanie klatki. Chociaż interfejs API do określania czasu, którego używałem (doskonała funkcja requestAnimationFrame firmy WebKit), nie obsługuje bezpośrednio 30 FPS, można to zrobić w bardzo prosty sposób. Chociaż nie jest to tak eleganckie jak w przypadku jawnego interfejsu API, 30 FPS można osiągnąć, wiedząc, że interwał RequestAnimationFrame jest dopasowany do synchronizacji pionowej monitora (zwykle 60 FPS). Oznacza to, że musimy zignorować wszystkie inne wywołania zwrotne. Jeśli masz funkcję zwracającą „Tick”, która jest wywoływana za każdym razem, gdy zostanie wywołana „RequestAnimationFrame”, możesz to zrobić w ten sposób:

var skip = false;

function Tick() {
skip = !skip;
if (skip) {
return;
}

// OTHER CODE
}

Jeśli chcesz zachować szczególną ostrożność, sprawdź, czy podczas uruchamiania komputera VSYNC nie jest ustawiony na 30 FPS lub poniżej tej wartości, i w tym przypadku wyłącz pomijanie. Nie zauważyłem jednak tego w żadnej z testowanych przeze mnie konfiguracji komputerów stacjonarnych ani laptopów.

Dystrybucja i zarabianie

Ostatnim obszarem, który zaskoczył mnie w portowaniu Bouncy Mouse na Chrome, była monetyzacja. W ramach tego projektu wyobrażałem sobie gry HTML5 jako ciekawy eksperyment, który pozwoli mi poznać nowe technologie. Nie zdawałem sobie sprawy, że port będzie dostępny dla bardzo dużej liczby osób i będzie miał duży potencjał do zarabiania.

Pod koniec października w Chrome Web Store pojawiła się gra Bouncy Mouse. Dzięki opublikowaniu w Chrome Web Store mogłem wykorzystać istniejący system zwiększania widoczności, zaangażowania społeczności, rankingów i innych funkcji, do których przywykłem na platformach mobilnych. Zaskoczyło mnie, jak duży zasięg ma sklep. W ciągu miesiąca od premiery aplikacja została zainstalowana prawie 400 tysięcy razy, a ja już korzystałem z zaangażowania społeczności (zgłaszanie błędów, opinie). Inną rzeczą, która mnie zaskoczyła, był potencjał aplikacji internetowej do zarabiania.

W grze Bouncy Mouse zastosowano jedną prostą metodę zarabiania – baner reklamowy obok treści gry. Jednak ze względu na szeroki zasięg gry okazało się, że ten baner reklamowy przyniósł znaczne przychody. W okresie szczytowego zainteresowania aplikacja wygenerowała przychody porównywalne z tymi z najpopularniejszej platformy, czyli Androida. Jednym z czynników, który się do tego przyczynia, jest to, że większe reklamy AdSense wyświetlane w wersji HTML5 generują znacznie wyższe przychody z wyświetlenia niż mniejsze reklamy AdMob wyświetlane na Androidzie. Co więcej, baner reklamowy w wersji HTML5 jest znacznie mniej uciążliwy niż w wersji na Androida, co pozwala na płynniejszą rozgrywkę. Ogólnie rzecz biorąc, bardzo pozytywnie zaskoczył mnie ten wynik.

Znormalizowane zarobki na przestrzeni czasu.
Znormalizowane zarobki na przestrzeni czasu

Chociaż zarobki z gry były znacznie wyższe niż oczekiwano, warto zauważyć, że zasięg Chrome Web Store jest nadal mniejszy niż w przypadku bardziej dojrzałych platform, takich jak Android Market. Chociaż gra Bouncy Mouse szybko awansowała do 9. miejsca na liście najpopularniejszych gier w Chrome Web Store, od czasu jej premiery liczba nowych użytkowników znacznie zmalała. Mimo to gra nadal się rozwija i nie mogę się doczekać, jak będzie wyglądała platforma w przyszłości.

Podsumowanie

Przenoszenie gry Bouncy Mouse do Chrome przebiegło znacznie płynniej, niż się spodziewałem. Poza drobnymi problemami z dźwiękiem i wydajnością Chrome okazał się świetną platformą dla istniejących gier na smartfony. Zachęcam wszystkich deweloperów, którzy do tej pory unikali tej funkcji, do jej wypróbowania. Jestem bardzo zadowolony z procesu przenoszenia i nowej grupy odbiorców, do której docieram dzięki grze w formacie HTML5. Jeśli masz pytania, napisz do mnie e-maila. Możesz też zostawić komentarz poniżej. Będę regularnie sprawdzać te komentarze.