Portowanie gier z C/C++ do przeglądarki
30.04.2017 13:43
Ten krótki wpis będzie traktować o przenoszeniu gier napisanych dla GNU/Linux do przeglądarek. Tak przerobioną grę można potem wrzucić na serwer i grać niezależnie od systemu operacyjnego. Wszystko, czego potrzebujemy, to nowoczesna przeglądarka internetowa. Co do przeniesienia, to potrzebujemy zainstalować emscripten(kroku nie opiszę), kod źródłowy w C. System budowania gry musi korzystać z pliku ./autogen, configure lub CMakeList.txt. Potrzeba też trochę samozaparcia.
Mozilla jakiś czas temu(jeszcze przed ogłoszeniem przez Google tworzenia NaCl) ogłosiła chęć stworzenia Posiksowej maszyny wirtualnej dla przeglądarek. Pomysł spotkał się z falą krytyki, że to zagraża neutralności sieci. Mozilla jednak zrealizowała swój plan, co pozwala niektóre programy z GNU/Linuksa przenosić do przeglądarki.
Trochę teorii
Emscripten to skrośny kompilator, który przenosi kod napisany dla systemu GNU/Linux do ASMJS i ewentualnie dodatkowo WebAssembly. Oba języki są wynalazkiem Mozilli, zarówno jak emscripten. Właściwie, to ASMJS nie jest całkiem odrębnym językiem, a podzbiorem JavaScript. WebAssembly to coś w stylu bytecodu Javy i jest on interpretowany przez nowoczesne przeglądarki, a starszym możemy zaserwować interpreter napisany w JavaScripcie. WebAssembly właściwie samemu nic nie może - może jedynie wykonywać obliczenia, zapisywać coś do pamięci, odczytywać coś z niej i wywoływać procedury/funkcje JavaScriptowe, jednak używa się WebAssembly w celu przyśpieszenia ładowania stron, a można także go użyć do ukrycia pewnych rzeczy. Myślę jednak, że pomimo możliwości użycia WebAssembly do ukrycia pewnych rzeczy, to nie jest to złem z powodu pracy w sandboksie i typowo obliczeniowej natury.
Emscripten dostarcza między innymi:
- emcc - kompilator
- emconfigure - tym narzędziem wywołujemy autogen, configure, a niekiedy trzeba nim uruchomić także make
- emmake - tym narzędziem uruchamiamy make, make install i tym podobne
Zaczynamy
Pierwsze, co nam jest potrzebne, to kod źródłowy naszej gry. Możemy przenosić własną grę lub poszukać innej. Dobrymi grami są gry bazujące na OpenGL, SDL2, SDL. Trzeba jednak nadmienić, że emscriptowe wersje wymienionych bibliotek nie są pełne, więc nie wszystkie gry będzie dało się prosto przenieść. Kolejnym problemem jest natura przeglądarki. Większość gier korzysta z nieskończonym pętli, a przeglądarki nie mogą na takie zachowanie pozwolić. Dlatego każdą procedurę z pętlą nieskończoną przerabiamy na:
- Inicjalizację
- Pętlę właściwą
Dodatkowo deklaracje wszystkich zmiennych z procedury zawierającej pętlę należy umieścić w specjalnej strukturze. Do inicjalizacji dodajemy (na samym początku) kod odpowiedzialny za utworzenie nowej struktury, naszego nowego typu, w pamięci, potem dodajemy cały kod przed pętlą niekończoną i zwracamy z tej funkcji utworzoną strukturę. Aha... Każde odwołanie się do zmiennej lokalnej z przerabianej procedury trzeba przerobić na odwołanie się do pola utworzonej struktury. Do procedury pętli właściwej umieszczamy całe ciało pętli. Następnie przerabiamy każdy break na np:
[code=C++] cancel_main_loop(1); return; [/code]
cancel_main_loop(1) jest tylko przykładem wyciągniętym z gry, którą przeniosłem, a mianowicie 7kaa. Anuluje to wywołanie naszej pętli i zwalnia pamięć naszej struktury(jakbym w ramach parametru przekazał 0, zamiast 1, to pamięć nie zostałaby zwolniona).
Obsługa pętli
Teraz bardzo ważny fragment - zajmuje się on odpowiednim neutralizowaniem minusów webowej natury naszej gry. Jest to prawdziwa pętla, która wywołuje pętlę właściwą, a tą prawdziwą pętlę wywołuje przeglądarka. Oto przykład:
[code=C] #include <emscripten.h> #include <OBOX.h> #include "loop.h"
struct loop_information *main_loop;
void set_main_loop( void (*iterator)(void*), // single step of main loop void *data, // Data passed to iterator void (*destructor)(void*) // will remove data (if present) ) { if (NULL == main_loop) { main_loop = (struct loop_information*) malloc(sizeof(*main_loop)); } else if (NULL != main_loop->destructor) { main_loop->destructor(main_loop->data); } main_loop->iterator = iterator; main_loop->data = data; main_loop->destructor = destructor; }
void push_loop( void (*iterator)(void*), // single step of main loop void *data, // Data passed to iterator void (*destructor)(void*) // will remove data (if present) ) { struct loop_information *nloop = (struct loop_information*) malloc(sizeof(*nloop)); nloop->iterator = iterator; nloop->data = data; nloop->destructor = destructor; nloop->prev = main_loop; main_loop = nloop; }
void prepend_each_loop( void (*iterator)(void*), // single step of main loop void *data, // Data passed to iterator void (*destructor)(void*) // will remove data (if present) ) { struct loop_information **pointer = &main_loop; struct loop_information *nloop = (struct loop_information*) malloc(sizeof(*nloop)); nloop->iterator = iterator; nloop->data = data; nloop->destructor = destructor; nloop->prev = NULL; while (NULL != *pointer) { pointer = &(*pointer)->prev; } *pointer = nloop; }
void disable_main_loop() { main_loop->iterator = NULL; main_loop->data = NULL; main_loop->destructor = NULL; }
void cancel_main_loop(char destroy) { struct loop_information *ploop; if (!main_loop) { SDL_ShowSimpleMessageBox( SDL_MESSAGEBOX_ERROR, "Seven Kingdoms - Runtime Error", "Main loop is NULL!", NULL ); return; } ploop = main_loop->prev; if (destroy) { if (main_loop->destructor) { main_loop->destructor(main_loop->data); } else { // TODO: Causing error //free(main_loop->data); } } free(main_loop); main_loop = ploop; }
void browser_main_loop(void *data) { if (NULL == main_loop->iterator) { SDL_ShowSimpleMessageBox( SDL_MESSAGEBOX_ERROR, "Seven Kingdoms - Runtime Error", "Main loop is NULL!", NULL ); } try { main_loop->iterator(main_loop->data); } catch (GoToLoop) { } }
void *get_destructor_for_current_loop(void) { return (void*) main_loop->destructor; }
void wait_data_to_be_synced(struct data_sync_callback *mesh) { #if 1 EM_ASM_ARGS({ if (Module.synced == 1) { Module.Runtime.dynCall('vi', $0, [ $1 ]); } }, mesh->callback, mesh->data); #endif }
[/code]
Ważnym elementem jest wait_data_to_be_synced. Jeżeli chcemy móc np. zapisywać stany gry, to musimy synchronizować bazę danych przeglądarki z tym, co mamy w pamięci przeglądarki, po każdym zapisie, a także przy uruchomieniu. Do tego powrócę później. Innym elementem jest browser_main_loop. Ta funkcja wywołuje naszą pętlę. Wywołanie następuje w bloku try .. catch, gdyż możemy odkładać na stos pętli, gdy uruchomiona jest inna pętla. Gdyby nie throw po odłożeniu elementu na stos, to wykonałyby się elementy zaraz po wyjściu z kodu inicjalizatora.
Prepend_each_loop jest potrzebne podczas inicjalizacji programu. Jeżeli chcemy wykonać oczekiwanie na zsynchronizowanie danych, to odkładamy każde takie oczekiwanie, a także pierwszą(główną) pętlę programu, za pomocą tej funkcji, Push_loop działa podobnie, jak prepend_each_loop, lecz odkłada element na górę stosu, a więc odłożona pętla zostanie wykonana zaraz po wyjściu z iteracji obecnej pętli.
[code=C] throw GoToLoop(); [/code]
Co zrobić z kodem po pętli gry?
Jeżeli mamy na myśli główny poziom pętli gry, to nic - po zamknięciu karty, przeglądarka zwolni zasoby. Co jednak z pozostałym pętlami? Tutaj wykorzystujemy push_loop i to, co mamy po pętli przenosimy do nowej funkcji. Push_loop zostanie omówiona potem. Ważnym jest, by nie wywoływać cancel_main_loop z parametrem 1 przed return w pętli właściwej, bo to zwolni nam naszą strukturę.
System plików
Jeżeli nasza gra ma jakieś dane, które wczytuje, to musimy zadbać o ich załadowanie przez przeglądarkę. Najprostszym sposobem jest użycie flagi -‑preload-file=<katalog lub plik>, przy tworzeniu pliku html przez emcc. Jeżeli chcemy coś zapisywać, co ma przetrwać sesję, to musimy zamontować odpowiedni system plików. To, jak tego dokonać:
[code=C/JavaScript] EM_ASM({ FS.mount(IDBFS,{},"/7kaa-home"); Module.synced = 0; FS.syncfs(true, function(error) { assert(!error); Module.synced = 1; }); }); [/code]
Makro EM_ASM pozwala nam na robienie wstawek JavaScriptowych w kodzie C. W powyższym przykładzie jedynie montujemy nasz system plików. Pierwszy parametr przekazany do FS.syncfs mówi, czy dane mają być odczytane(true) lub zapisane(false).
Oczekiwanie na synchronizację
Oto kod odpowiedzialny za synchronizację:
[code=C] void wait_data_to_be_synced(struct data_sync_callback *mesh) { #if 1 EM_ASM_ARGS({ if (Module.synced == 1) { Module.Runtime.dynCall('vi', $0, [ $1 ]); } }, mesh->callback, mesh->data); #endif } [/code]
Przedstawiłem go już ponownie. EM_ASM_ARGS jest makrem C, które pozwala na wstawienie kodu JavaScript, do którego przekażemy parametry. Struktura data_sync_callback zawiera wskaźnik na procedurę, która ma zostać wywołana, czyli callback, a także dane, które trzeba przekazać (data), Wait_data_to_be_synced musi być wywołana za pomocą funkcji browser_main_loop. Ważne jest, by nasza procedura przekazana za pomocą pola callback wywoływała cancel_main_loop(0) lub cancel_main_loop(1). Podsumowując, wykonujemy, po zapisie stanu gry.
[code=C] struct data_sync_callback *ds = (struct data_sync_callback*) malloc(sizeof(*ds)); ds‑>callback = choćby_tylko_cancel_main_loop ds‑>data = "Data synced"; push_loop((void(*)(void*))wait_data_to_be_synced, (void*) ds, NULL); [/code]
i
[code=C] void choćby_tylko_cancel_main_loop(char *status) { puts(status); cancel_main_loop(0); } [/code]
Teraz pora, by omówić Module.Runtime.dynCall. Pierwszy argument, to sygnatura funkcji, kolejny to adres funkcji, a następnie tablica argumentów. Pierwszą literą w sygnaturze, to typ zwracanej wartości. W naszym wypadku jest to void('v'). Kolejnymi są typy przyjmowanych argumentów.W naszym wskaźnik, czyli ('i' od integer; tak - wskaźnik w JavaScripcie to to samo, co liczba całkowita).
Kompilacja
W większości przypadków
emconfigure ./configure emmake make mv ścieżka_do_pliku_końcowego ścieżka_do_pliku_końcowego.bc emcc ścieżka_do_pliku_końcowego.bc -o program.html
Emscripten sam wygeneruje dla nas stronę internetową.
Podsumowanie
Pomijając fakt, że możemy natknąć się na braki niektórych funkcji, np. biblioteki SDL lub bibliotek, to przenoszenie programów na przeglądarkę z C/C++ nie jest bardzo trudne. Największym problemem tak naprawdę są wadliwe skrypty konfiguracyjne i konieczność owijania pętli w pętlę przeglądarki, jak i brak możliwości wykorzystywania wielu, zagnieżdżonych pętli gry, co rozwiązałem przez moją implementację pętli (wywołania push_loop, wykorzystanie throw GoToLoop(), itd.). Do emscripten zostało przeniesionych dużo interpreterów języków programowania, w tym maszyna wirtualna Javy, Pythona, itd. Został przeniesiony także DosBox. Jest mnóstwo ciekawych projektów, a więcej czeka na przeniesienie.
Jeden z moich projektów to battleof7(przeniesiona wersja 7kaa), które jest dostępny na sourceforgu.