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ł fakt, ż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 w Chrome Web Store interesującą możliwość. Sklep internetowy nie jest w żaden sposób pusty, ale jego katalog wysokiej jakości gier w formacie HTML5 dopiero zaczyna się rozwijać. 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 urządzeniach z Androidem(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 kryteriów, które mnie interesują). 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 wystarczająco mała ilość, aby można było 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 praktykach: nie pisać zbędnego kodu i wykorzystywać w jak największym stopniu kod do wstępnego przetwarzania (nie w czasie 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 pomocnicze. Jeśli jednak nie planujesz używać pomocnika wielokrotnie, zwykle powoduje on tylko rozrost 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 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 użycie 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 platformach, które mnie interesują, 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 lub w HTML5 nie należy (jeszcze) do tej kategorii, więc nie ma potrzeby komplikowania sytuacji, dopóki nie będzie to konieczne.

Nie bagatelizuj 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 pozostają bez zmian. 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 podstawie wszystko, co trzeba wiedzieć o przenoszeniu numeru. 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 wykorzystanie. Nawet w najnowszych przeglądarkach często spotykałem się z dziwnym zachowaniem. Chrome zdaje się na przykład ograniczać liczbę jednoczesnych elementów audio (źródło), 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 formatu audio HTML5, gdy 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 HTML5 Audio). 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”, „pauzuj” ani „zatrzymaj”. Po zapewnieniach Google, że moje obawy są nieuzasadnione, znów zagłębiłem się w interfejs API. Po zapoznaniu się z kilkoma przykładami i przeprowadzeniu dalszych badań stwierdziłem, że Google ma 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ł Rozpoczynanie 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 to obejść, 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 dźwięku w przeglądarce

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 jest 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 potrzebowałem 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 jest nieco bardziej skomplikowane. Aby zilustrować możliwości interfejsu Web Audio API, zobacz ten wykres:

Trivial Web Audio Graph
Prosty wykres audio 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, 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żytkownika.

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ła 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 może odtwarzać 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

Kolejną kwestią, która niepokoiła mnie w przypadku portowania kodu JavaScript, 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 już tak dobrze. 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 będzie 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 na sklonowanie 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, a po jej wprowadzeniu 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 odpadków 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 potrzebował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 pamiętałem jeszcze jeden z najstarszych trików: jeśli nie możesz osiągnąć 60 FPS, użyj 30 FPS. Ogólnie wiadomo, że płynne 30 FPS jest znacznie lepsze niż trzęsące się 60 FPS. W rzeczywistości nie otrzymaliśmy jeszcze ani jednej skargi ani komentarza dotyczącej tego, że gra działa z prędkoś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 nieefektywnego 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ę z powrotem „Tick”, która jest wywoływana za każdym razem, gdy zostanie wywołana funkcja „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 gry HTML5 miały być dla mnie ciekawym eksperymentem, który pozwoliłby 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ększający widoczność, zaangażowanie społeczności, rankingi i inne funkcje, 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.

Bouncy Mouse ma 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 na poziomie porównywalnym z najpopularniejszą platformą, czyli Androidem. 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 w rankingu 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.