Opublikowałem dziś kolejny ze swoich projektów. W tytule notki napisałem, że nowy, ale nie jest to do końca prawda.
Historia
Labirynt 4D jest programem, którego pierwotną wersję stworzyłem około 10 lat temu. Mój kolega z liceum wpadł wówczas na pomysł, że dość zabawną grą mogłaby być strzelanka, w której świat jest 4-wymiarowy, ale naraz widać tylko 3 z nich. W jego wizji w każdym momencie 3 z 4 współrzędnych byłyby wybrane w celu określenia, która część świata ma być rysowana, a gracz mógłby w każdej chwili zmienić ten zestaw. To mogłoby prowadzić do zabawnych sytuacji, w których gracz widzi sunący na niego płaski przekrój innego gracza, który na moment staje się w pełni 3-wymiarowy (przez chwilowe "wskoczenie" w jego przestrzeń), zabija go i znowu się spłaszcza. Postanowiłem wtedy stworzyć "proof-of-concept" czegoś takiego, jednak uznałem, że ciekawsze od dyskretnego przełączania zbiorów współrzędnych będzie obracanie widocznego przekroju w sposób ciągły.
Pierwotna wersja powstała w C++ i jej kod jest wciąż dostępny na GitHubie. Korzystała ona bezpośrednio z WinAPI, by stworzyć okno, i wywołań OpenGL, by rysować kształty. Kod jest strasznie chaotyczny (gdyż nie byłem wtedy szczególnie doświadczonym programistą...), ale niezbyt długi i działa. Bezpośrednie korzystanie z WinAPI związało go jednak dość mocno z systemem Windows.
Kilka lat później podjąłem próbę przepisania Labiryntu w bardziej przenośny sposób, z użyciem biblioteki Qt. Jakąś część tej próby umieściłem w osobnej gałęzi w repozytorium na GitHubie, ale chyba nigdy nie doprowadziłem jej do końca. Podlinkowany kod chyba nawet się kompiluje, ale zdaje się, że nie działa.
Jakieś 2 lata temu postanowiłem spróbować stworzyć wersję na Androida. Udostępniłem na GitHubie repozytorium, w którym zawarłem kod potrafiący wyświetlić obracający się hipersześcian, ale na tym się zatrzymałem. Nie potrafiłem wymyślić dobrego sposobu sterowania z 9 stopniami swobody (ruch w 3 kierunkach i 6 obrotów; ruch w 1 kierunku celowo pominięty) na dotykowym ekranie i w końcu straciłem zapał.
Ostatnio nabrałem ochoty, aby spróbować przepisać Labirynt w Ruście. Interesuję się tym językiem od dłuższego czasu i często nachodzi mnie ochota, żeby go sobie poćwiczyć, ale mam problemy ze znajdowaniem sobie projektów, które wystarczająco by mnie wciągnęły. Ten projekt okazał się odpowiedni.
Struktura projektu
Dla zrozumienia, o czym mowa, warto najpierw przeczytać opis projektu, który podlinkowałem na początku artykułu. Wyjaśnia on, co program właściwie robi, co jest dość kluczowe dla zrozumienia, jak to robi ;)
Struktura starej wersji w C++
Pierwotna, napisana w C++ wersja programu była skonstruowana bardzo prosto, choć niezbyt czysto. Sercem programu była klasa Graph4D
, która zawierała kolejkę kształtów do narysowania, stos macierzy, niektóre ustawienia OpenGL oraz metody pozwalające na dodawanie kształtów do kolejki i zmienianie aktualnie stosowanej transformacji. Główna metoda tej klasy rysowała zawartość kolejki z wykorzystaniem OpenGL, wcześniej dokonując serii obliczeń - przede wszystkim, każdy z kształtów w kolejce musiał być "przecięty" z aktualną hiperpłaszczyzną kamery, co generowało kształty do narysowania przy wykorzystaniu już zwykłych technik 3D.
Hiperpłaszczyznę kamery opisywał obiekt klasy Camera
. Miał on położenie i orientację, opisywaną czterema wektorami, określającymi kierunki w prawo, górę, przód i "ana" od kamery (przyjęło się nazywać kierunki wzdłuż czwartej osi "ana" i "kata" - analogicznie do lewo/prawo, przód/tył, góra/dół). Wektor w kierunku "ana" służył jednocześnie za wektor prostopadły do wyświetlanej hiperpłaszczyzny.
Całość bazowała na dwóch klasach, vector4
i matrix4
, reprezentujących odpowiednio 4-wymiarowy wektor oraz 4-wymiarową macierz (która była w rzeczywistości macierzą 5x5, aby mogła opisywać również translacje).
Na tym wszystkim były zbudowane 3 dodatkowe klasy - gracz (LPlayer
), ściana (LWall
) i cel (LTarget
). Każda z nich dziedziczyła po klasie Object
, definiującą wirtualną metodę doYourJob()
odpowiedzialną za rysowanie obiektu na ekranie. Dodatkowo istniała klasa kolejki obiektów, która przechowywała zbiór obiektów typu Object
i mogła wywoływać na nich po kolei metodę doYourJob()
.
Wszystkie obiekty były tworzone w funkcji main()
(z użyciem funkcji pomocniczych, typu wczytywanie definicji poziomu z pliku), dodawane do kolejki, po czym w pętli szła obsługa kolejki, rysowanie klatki i tak w kółko. Wszelkie sterowanie było obsługiwane również w głównym pliku.
Pisząc wszystko od nowa, postanowiłem oprzeć się na starej wersji (warto jednak skorzystać z wcześniejszych doświadczeń), ale zaprojektować całość nieco czyściej.
Struktura wersji w Ruście
Wersję Rustową podzieliłem podobnie - na bibliotekę graph4d
, zawierającą ogólną logikę rysowania 4-wymiarowych obiektów, oraz samą aplikację 4d-labyrinth
, która buduje na nich obiekty gry.
Biblioteka graph4d
jest skonstruowana podobnie jak w wersji C++, jednak nieco bardziej rozbita na niezależne części. Moduł geometryczny, tak jak w C++, zawiera obsługę 4-wymiarowych wektorów i macierzy. Mając nieco lepsze pojęcie o tym, na czym polega rozszerzanie macierzy do rozmiaru 5x5, wektory 4D stworzyłem również jako mające 5 współrzędnych, gdzie piąta współrzędna jest jedynie liczbą, przez którą należy dzielić pozostałe 4, aby uzyskać właściwe wartości współrzędnych wektora. Potencjalnie umożliwi to rozszerzenie biblioteki w przyszłości o rzutowanie z 4D na 3D.
Możliwe do narysowania kształty, które w C++ opisywałem strukturą zawierającą numer typu kształtu i od 1 do 4 wierzchołków, tu opisałem rustowym wyliczeniem (enum
). Dzięki temu kompilator sprawdza, czy każda możliwość jest obsłużona.
Największa różnica jest chyba w implementacji kamery. W C++ kamera była osobnym obiektem, wykorzystywanym przez renderer do określenia położenia kształtów w przestrzeni 3D. W Ruście zaimplementowałem kamerę jako cechę (ang. trait). Cecha ta zapewnia, że implementująca ją struktura wie jak określić widoczną hiperpłaszczyznę i jak transformować punkty do lokalnego układu współrzędnych. Ponieważ w Labiryncie 4D i tak wiązałem kamerę z graczem, postanowiłem, że tutaj zaimplementuję tę cechę strukturze opisującej gracza. W C++ każdy ruch musiał transformować osobno gracza i kamerę, tu dzięki temu posunięciu transformacja będzie dokonywała się raz, a jednocześnie nie tracimy ogólności (można zaimplementować tę cechę dowolnej innej strukturze i mieć osobną kamerę).
Obiekt renderujący, jak poprzednio, oblicza przecięcia kształtów z widoczną hiperpłaszczyzną (przy czym sprowadza się to do wywołania metody intersect()
na obiekcie kształtu z podaną hiperpłaszczyzną zwróconą z kamery, w przeciwieństwie do wersji C++, w której za wszystko to odpowiedzialny był renderer), po czym wrzuca rezultat w OpenGL. Komunikacja z OpenGL odbywa się przez fantastyczną bibliotekę Glium.
Musiałem w tym momencie trochę poduczyć się nowych możliwości OpenGL. Moja znajomość tego API tkwiła w czasach, gdy wywoływało się glBegin()
, potem wielokrotnie glVertex
i na koniec glEnd
. Obecnie jest to nadal możliwe, jednak zaleca się korzystanie z buforów wierzchołków i shaderów i to jest jedyna opcja w Glium. Na szczęście shadery dokonujące tych przekształceń, co stare OpenGL, są bardzo proste, a całą resztę robi się w Glium całkiem bezboleśnie (aczkolwiek zrozumienie jak właściwie tę bezbolesność wykorzystać chwilę mi zajęło).
To właściwie podsumowuje bibliotekę. Sama aplikacja również działa podobnie, są struktury Player
, Wall
oraz Target
. Każda z nich implementuje cechę GameObject
, która udostępnia metodę rysującą obiekt. Oprócz tego, ściany i cel implementują Collidable
, służące wykrywaniu kolizji z graczem.
Nieco inaczej zaimplementowałem kolejkę obiektów. Za kolejkę służy struktura Level
, która wczytuje definicje ścian oraz celu z pliku i tworzy odpowiednie obiekty. Zawiera ona również dwie metody - game_objects()
oraz collidables()
, zwracające iteratory po wszystkich obiektach implementujących odpowiednio GameObject
oraz Collidable
. Tu niesamowicie przydatna okazała się eksperymentalna składnia impl Trait
, która bardzo upraszcza określenie typów zwracanych przez te metody, jednak niestety sprawia, że cały program wymaga korzystania z wersji Nightly kompilatora.
Główna pętla programu też działa inaczej niż w wersji C++. Tu chciałem, aby to gracz był odpowiedzialny za swój ruch. Główna pętla wobec tego zapisuje jedynie stan klawiatury w specjalnej strukturze, którą przekazuje graczowi. Gracz analizuje ją i wykonuje ruch. Tu pojawił się jednak problem z obsługą kolizji.
W wersji C++ sprawa wyglądała tak - główna pętla sprawdza stan klawiszy, wywołuje na graczu odpowiednie metody przesuwające go, po czym sprawdzane są kolizje. Jeśli zaszła jakaś kolizja, ruch gracza był cofany. To wszystko było możliwe dzięki temu, że całość odpowiedzialności za przesuwanie obiektów spoczywała na głównej pętli.
W Ruście, gdy gracz zdecyduje na podstawie stanu klawiatury, że się przesuwa, nie bardzo mamy co z tym zrobić. Nie możemy też sprawdzić kolizji przed ruchem - jeśli już zaszła kolizja, to jest za późno i ruch przed nią nie powinien był być wykonany. Rozwiązałem sprawę tak - metoda poruszająca gracza zwraca wartość, która opisuje, co gracz zamierza zrobić. Kolizje są sprawdzane na podstawie tego zamiaru i dopiero jeśli wszystko jest w porządku, zamiar zostaje przekazany graczowi do realizacji.
Podsumowanie
Projekt ten miał dla mnie dużą wartość edukacyjną. Stworzyłem nietrywialny program w Ruście, którego kod w miarę zadowala moje poczucie estetyki. Nauczyłem się nieco o nowoczesnym OpenGL, z którego wcześniej raczej nie korzystałem (trochę miałem z tym styczności przy okazji pisania wersji na Androida, ale niewiele zapamiętałem). Dowiedziałem się na własnej skórze, na czym polega przydatność składni impl Trait
, która wcześniej wydawała mi się niepotrzebna. No i najważniejsze, poćwiczyłem pisanie w Ruście (choć tego akurat będę miał teraz dużo, gdyż od niecałych 2 tygodni programowanie w Ruście jest także moją pracą ;) ).
Labirynt 4D można pobrać ze strony projektu i pobawić się nim we własnym zakresie :)