Jak programować gry? Najważniejsze elementy w grach 2D

1. Wstęp

Tworzenie gier to coś o czym marzyłem od początku mojej przygody z programowaniem. Przez czas nauki wiele razy błądziłem, próbowałem różnych technik programowania gier, często bardzo prymitywnych… byłem wtedy po prostu średniaczkiem a i mój młody wiek też na to wpłynął bo nie do końca rozumiałem co ja w ogóle piszę. Ale próbowałem. Przez moje samozaparcie i dociekliwość przebyłem całkiem spory kawałek programistycznej drogi i wylądowałem tutaj. Dlaczego tu jestem?

Już od jakiegoś czasu planowałem podzielić się swoim doświadczeniem w kwestii gier jednak zawsze coś mnie zatrzymywało i nie mogłem się zabrać do tego. Tym razem dzięki tej grupie czuję, że mi się to uda. Zapraszam na lekturę.

1.1. O czym jest ten artykuł?

Ten artykuł ma Ci pokazać w jaki sposób zaprojektować swój kod tak, żeby rozwijanie go było banalnie łatwe, szybkie i przyjemne. I pisząc „szybkie” mam na myśli naprawdę szybkie. Omówimy kilka ważnych technik, które są w rzeczywistości podstawą wydajnego pisania gier. Tematy, które poruszymy w tym artykule to:

  1. Jak poprawnie rozpocząć projekt i jakich tendencji powinniśmy się wystrzegać?
  2. Jak uporządkować główne dane gry i jak nimi zarządzać?
  3. Jak ułatwić sobie wczytywanie tekstur poprzez menedżer tekstur?
  4. Czym jest i z czego składa się scena i jak dzięki niej łatwo zarządzać elementami w grze?
  5. Jak skomponować hierarchię klas dla obiektów w grze tak, by bardzo łatwo można było nimi zarządzać?

Aby powiązać wszystkie punkty tego artykułu ze sobą (bo w końcu każdy z nich przyczynia się do lepszego programowania gier) kod napiszemy tak, że będzie on jedną spójną całością. W zasadzie to co uzyskamy pod koniec artykułu zwykłem nazywać „bazą pod grę” jednak jeśli piszemy to na tak dużą skalę można to swobodnie nazwać prostym silnikiem pod gry 2D . Zwieńczeniem tego artykułu będzie film, w którym zamierzam stworzyć grę Bomberman korzystając właśnie z przygotowanej przez nas bazy. Będzie on miał na celu pokazać jak sprawnie można pisać kod dzięki takim technikom.

Niniejszy materiał opiera się przede wszystkim o programowanie obiektowe i przeznaczony jest dla programistów, którzy potrafią je wykorzystać.

1.2. Czego użyjemy?

Na warsztat bierzemy konkretnie gry komputerowe.

W artykule posługuję się językiem C++ przy współpracy z SFML 2.4.1. Nie martw się, jeśli jesteś programistą jakiegoś innego języka a tym bardziej jeśli tylko korzystasz z innej biblioteki to nie ma to większego znaczenia. Ten sposób programowania można wykorzystać w dowolnym języku zorientowanym obiektowo a SFML jest wykorzystane tutaj tylko jako biblioteka pomocnicza do wyświetlania. Niemniej jednak jeśli piszesz w innym języku to wciąż warto posiadać podstawową wiedzę z C++, żeby w ogóle orientować się o czym piszę.

Jeśli tak jak ja korzystasz z Visual Studio 2015 i nie wiesz jak skonfigurować projekt by współdziałał z biblioteką SFML to zapraszam do obejrzenia niedawno nagranego przeze mnie filmu:

2. Początek projektu

Ten pierwszy krok zawsze jest najważniejszy. To od niego bowiem zależy jak rozwinie się Twój projekt. Zaczynasz z czystą kartą, myślisz „od czego by tu zacząć?”.

2.1. Czego się wystrzegać?

Każdy projekt gry na pewno będzie posiadał jakieś główne zmienne, obiekty – takie, które powinny być widoczne praktycznie z każdego miejsca w kodzie. Wymieńmy sobie kilka z takich rzeczy:

  • okno gry
  • wczytane zasoby
  • aktualny stan gry
  • szybkość gry

Od Ciebie zależy jak to zrobisz. Najprostszym rozwiązaniem będzie użycie zmiennych globalnych.

Na początku. Już przy bardzo wczesnym etapie pisania zauważysz, że zarządzanie zmiennymi globalnymi jest okropnie nieintuicyjne i wiele sytuacji nie możesz przewidzieć.

Jeżeli jesteś zmuszony do użycia zmiennych globalnych to powinno dać Ci to sygnał, że powinieneś przeprojektować swój kod.

Na myśl przychodzi kolejny sposób – wszystkie ważniejsze dane umieszczamy w funkcji main (czy innej procedury startowej programu) a następnie do odpowiednich funkcji przekazujemy przez referencje dane, które są niezbędne do jej funkcjonowania.

Te podejście bardzo często spotykałem wśród początkujących programistów. Jest to wciąż nieprofesjonalny sposób, powodem jest tutaj ponownie cięższe zarządzanie danymi.
Przejdźmy już do konkretów – najbardziej intuicyjnym podejściem do zarządzania pamięcią jest oparcie jakiejś grupy danych na obiekcie. Dzięki konstruktorom i destruktorowi możesz z powodzeniem dopracować alokowanie i zwalnianie pamięci.

Właśnie z tego powodu można oprzeć całą aplikację / grę na klasie i dokładnie tak zrobimy.

2.2. Gra oparta na singletonie

Klasa gry nie będzie byle jaką klasa bo konkretnie zajmiemy się singletonem (klasa o wyłącznie jednej instancji, więcej o tym: Wzorce projektowe – Singleton ). Co nam da taki typ klasy? Przede wszystkim będziemy mogli dosłownie z każdego miejsca w kodzie uzyskać dostęp do naszej gry a to w przypadku gier jest wielce pożądane.

Zaczynamy od kodu, na którym skończyłem film, który umieściłem we wstępie. Tak jak założyłem przed chwilą – chciałbym oprzeć tą aplikację na klasie. Tworzę więc dwa nowe pliki – G ame.hpp oraz Game.cpp . Pierwszy będzie zawierał samą klasę zaś w drugim umieszczę jej definicję. Zaczynamy na początek od typowej klasy singleton:

Jak widać jest to prosta klasa przystosowana do tego, by potem umieścić w niej kod gry. Zablokowaliśmy jej możliwość manualnego tworzenia instancji spoza klasy poprzez przeniesienie konstruktora do sekcji private oraz usunięcie domyślnego konstruktora kopiującego i operatora przypisania. Zamiast tego, by dostać się do obiektu gry będziemy używali po prostu statycznej metody Instance() .

Cóż, to dopiero początek. Do tak przygotowanej klasy trzeba teraz przenieść kod aplikacji, który do tej pory prezentuje się mniej więcej tak:

Zaczniemy od skonfigurowania okna. Przenosimy je do naszej klasy CGame do sekcji private: sf::RenderWindow m_window;
Od razu doróbmy sobie „gettera” (nie lubię tego określenia), niech zwraca on referencję do naszego okna – przyda się to później: inline sf::RenderWindow& GetWindow() { return m_window; }

W tym momencie ktoś spostrzegawczy pewnie zauważy, że taki zapis nie ma sensu, bo po co ukrywać okno w sekcji private jeśli i tak mamy metodę, która zwraca referencję na to okno. Użytkownik w tym przypadku nadal ma pełny dostęp do okna.
Zrobiłem to z pełną świadomością, żeby nie bawić się w takie błahostki w artykule, który i tak już jest nad wyraz długi. Dostęp do pewnych funkcji będzie nam potrzebny w dalszej części artykułu i mimo, że mógłbym na około dostać się do tych funkcji to nie zamierzam jeszcze bardziej komplikować tego tematu.

Konfiguracja okna będzie przebiegać w konstruktorze już w liście inicjalizacyjnej (dzięki temu skorzystamy z konstruktora klasy sf::RenderWindow ). Od razu musimy zadbać by nasze okno zostało poprawnie zamknięte. Warto tutaj skorzystać z destruktora. Po szybkim przeniesieniu kod źródłowy wygląda tak:

Przyszedł czas na przeniesienie właściwego kodu gry. Umieścimy ją w nowo powstałej metodzie void CGame::Run() . Zanim to jednak zrobimy, chciałbym żebyśmy w jakiś sposób zaczęli przechowywać stan naszej aplikacji. Na sam początek skonstruujmy sobie enum , który będzie zawierał wszystkie te stany:

Status przechowujemy obok okna i również dodajemy do niego „gettera”:
inline Status GetStatus() const { return m_status; }
private:
Status m_status;
Wracamy do przenoszenia głównej części kodu. Wraz z tym ustawiamy odpowiednio status naszej aplikacji. Kod wygląda teraz o tak:

Już tłumaczę co stało się z pętlą główną programu. Tym razem zamiast sprawdzać czy okno jest otwarte, sprawdzamy czy status przypadkiem nie zmienił się i nie jest teraz CleaningUp (co oznacza, że aplikacja ma się teraz zamknąć). Gdy użytkownik naciśnie przycisk wyłączając aplikację zmieniamy status na CleaningUp , pętla główna się kończy a zarazem cała funkcja Run jest zakończona. Destruktor automatycznie sam zamyka okno i wszyscy żyją długo i szczęśliwie. Dodatkowo wprowadziłem tutaj taki obiekt jak sf::Color bgColor(30, 30, 30) , w którym przechowuję kolor, którym czyścimy okno.

Teraz czas na przejście do funkcji main . Jej jedynym zadaniem będzie pobranie referencji do obiektu CGame (jest to pierwsze wywołanie funkcji Instance() więc automatycznie tworzona jest instancja) a następnie uruchomienie metody Run() . Do końca projektu raczej nie będziesz musiał jej zmieniać, zmiany w grze już nie zależą od funkcji main .

Tak o to nasze dane uporządkowaliśmy w klasie gry.
Możesz już zacząć się cieszyć bo to pierwszy duży krok do bardzo wydajnego tworzenia gier.

2.3. Menedżer tekstur

Aby sprawnie zarządzać teksturami stworzymy sobie menedżer tekstur, który nam w tym pomoże. Poniższy rysunek pokrótce wyjaśnia jak on działa i jakie podstawowe funkcje powinien posiadać. Oprócz pokazanych niżej funkcjonalności rozszerzymy go o następujące metody:

  • sprawdzającą czy podana tekstura jest załadowana
  • usuwającą wszystkie tekstury z zasobów

Na pierwszy rzut oka już widać, że menedżer tekstur to nie kilka linijek kodu i wypadałoby jakoś go mądrze ulokować w kodzie. Zrobimy to tak samo jak robiliśmy z klasą CGame.

Kolejny raz tworzymy sobie dwa pliki, w których będzie zawarty cały kod menedżera. Będą nimi TextureManager.hpp (sama klasa), TextureManager.cpp (definicje).

Plik nagłówkowy przygotowujemy pod nasz menedżer. Będzie on klasą singleton z tą różnicą, że metody do zarządzania teksturami zrobimy statyczne – ogromnie będzie to pomagało w dalszej pracy z teksturami.

Zacznijmy od tego gdzie będziemy trzymali nasze tekstury. W tym celu stworzyliśmy sobie kontener std::unordered_map . Kilka razy widziałem (sam też w sumie tak kiedyś robiłem), że ludzie tworzą zbiór tekstur na std::map , która jest sortowana a operacje na niej nie są wykonywane ze stałą złożonością. Do zarządzania teksturami nie potrzebujemy sortowania. Żeby jakoś zacząć zarządzać tymi teksturami musimy dodać sobie funkcje, które podałem przed chwilą u góry. Wyglądają one tak:

  • Tekstury wczytujemy metodą CTextureManager::Load()
  • Usuwamy z pamięci metodą CTextureManager::Unload().
    Zamiast ścieżki do pliku jako unikalny klucz w naszym std::unordered_map podajemy własną nazwę tekstury.
    Oczywiście nie jest to rozkaz, możesz równie dobrze użyć ścieżki do tekstury, jednak z jednego prostego powodu nie zalecam tego rozwiązania. Zapytacie: cóż to za ważny powód panie Pawle? Odpowiedź brzmi: ścieżki nie zawsze odnoszą się poprawnie do przeznaczenia tekstur i często są dłuższe. Zrobicie jak chcecie jednak ja próbowałem już wielu metod i z nich wszystkich ta wydaje mi się najwłaściwsza.
  • Metoda CTextureManager::Cleanup() usuwa wszystkie tekstury z pamięci.
  • Metoda CTextureManager::Get() odpowiada za pobieranie tekstur z kontenera, jednak jeśli tekstura nie istnieje zwróci pusty wskaźnik.
  • Metoda CTextureManager::Exists() odpowiada za sprawdzanie czy podana tekstura jest już w zasobach menedżera.

Więc jak będzie wyglądała implementacja tych metod? Spójrz na ten kod i spróbuj zrozumieć jak to działa. Ja wyjaśnię to na dole.

Cóż, na pierwszy rzut oka wygląda to dosyć skomplikowanie, jednak jest to typowa definicja menedżera tekstur. Zacznijmy analizować od góry:

  • konstruktor zostawiamy pusty (nic szczególnego tam nie musi się dziać)
  • w destruktorze musimy usunąć każdą teksturę, która pozostała w zbiorze.
    Zastosowałem tu tzw. range-based for loop czyli coś podobnego do foreach , który znajdziemy w wielu językach programowania.
    W pętli napisałem delete (textureData.second) – dlaczego? Elementy, po których iterujemy, to tak naprawdę std::pair<const std::string, sf::Texture*> a więc musimy dostać się do wskaźnika, który w naszej parze znajduje się jako drugi ( second ) – stąd textureData.second .
  • funkcja Load() z tego co wcześniej napisałem, jeśli już istniała tekstura nazwana „x”, winna bezpiecznie wczytać ponownie teksturę i zamienić się z teksturą „x”. Ktoś by mógł pomyśleć, że wystarczy zwolnić tę teksturę tak samo jak robi to metoda Unload() a potem wczytać od nowa. Nie możemy jednak tak zrobić, gdyż niektóre rzeczy mogły korzystać z tekstury „x” i bezpieczniej będzie po prostu podmienić to co wyświetlają niż zostawić im zepsuty wskaźnik.
    Jeśli jednak tekstura wcześniej nie istniała to po prostu tworzymy sobie dynamicznie taką teksturę, wczytujemy ją i dodajemy do naszego kontenera.
  • funkcja Unload() wyszukuje teksturę poprzez metodę std::unordered_map::find . Jeśli tekstura nie została znaleziona to zwracamy po prostu false . Jeśli jednak tekstura została znaleziona to musimy ją usunąć. Ponownie widnieje tutaj zapis podobny do tego z destruktora jednak w tym przypadku użyliśmy -> jakby to był wskaźnik. Dlaczego więc on a nie kropka? Tym razem elementem textureIt jest iterator. W skrócie, jeśli mamy iterator , który odpowiada za obiekt o klasie „X” i chcemy np. odwołać się do pola „y” z tego obiektu to napiszemy w ten sposób: iterator->y . Tak samo w tym przypadku użyliśmy textureIt->second by odwołać się do wskaźnika sf::Texture . Więcej o iteratorach poznasz czytając to: STL Tutorial – Iterators .
  • funkcja Cleanup() robi praktycznie to samo co destruktor, jedynie zwraca ilość usuniętych tekstur.
  • funkcja Get() najpierw szuka tekstury poprzez metodę std::unordered_map::find . Jeśli tekstura nie została znaleziona to zwracamy pusty wskaźnik. Jeśli została znaleziona to zwracamy wskaźnik na tężę teksturę czyli po prostu textureIt->second .

Tak o to w krótki sposób zapewniliśmy sobie bardzo fajny menedżer tekstur, który znacznie umili naszą późniejszą pracę. Czas teraz przejść dalej.

3. Organizacja klas metodą Unreal Engine 4

Najpierw krótka historia jak poznawałem Unreal Engine 4 i dlaczego tak bardzo podoba mi się ich hierarchia klas.

Jakieś 2 i pół roku temu zaplanowałem stworzyć grę z bratem (który swoją drogą jest grafikiem). Jako że wtedy miałem już blisko 5, prawie 6 lat programowania za sobą i czułem się na siłach zaproponowałem, że stworzę trójwymiarowy silnik pod naszą grę. Wiedziałem, że będzie to piekielnie czasochłonne zadanie ale czułem, że dam radę. Zaczęły się prace nad grą. Na programowanie poświęcałem prawie cały swój czas i po 3 miesiącach pracy mieliśmy już jak na nas dwóch całkiem sporo osiągnięć. Silnik 3D oparty na DirectX 11 z własnym wrapperem pod WinAPI był w stanie z powodzeniem renderować animowane obiekty 3D jak i proste modele statyczne. Chcieliśmy sprawdzić nasze możliwości i przez to zbudowaliśmy prostą scenerię na cmentarzu, gdzie główny bohater biegał po malutkim obszarze i zbierał kilka rozrzuconych przedmiotów, miał ekwipunek, mógł zakładać jedną jedyną broń… i to w sumie tyle. Szkoda, że filmik, który wtedy nagraliśmy się nie zachował bo miałbym co wspominać. Idąc dalej – była rzecz, która totalnie mnie pokonała. Fizyka. Fizyka moi drodzy w grach 3D jest bardzo skomplikowana a jeszcze bardziej dla kogoś, kto pierwszy raz w ogóle ma z nią do czynienia. Przewyższyło mnie to. Nie byłem w stanie podpiąć nVidia PhysXa ani BulletPhysicsa pod mój silnik. W tym momencie zdecydowaliśmy się, że przenosimy wszystko na Unreal Engine 4 i tam dopiero poznałem co to ból. Moje blisko półtora letnie doświadczenie z UE4 to żenada, nawet nie chce tego wspominać (crash co 10 minut, ładowanie 7,5GB RAMu bez żadnego powodu, launcher UE4, który potrafił zabrać nawet 1GB RAMu wyświetlając kilka obrazków). Ale pośród tego wszystkiego wyłaniała się świetna hierarchia klas. Wszystko było tak uporządkowane, że aż nie chciało się wierzyć, że twórcy Unreala potrafili tak doszczętnie go pogrzebać.
Mówimy teraz o hierarchii obiektów na scenie itp.

Wygląda ona w ten sposób: podstawową klasą, dla czegokolwiek mogącego znaleźć się na scenie był Actor . W sumie ma to nawet przełożenie na rzeczywistość – aktorzy występują na scenie w teatrze. Actor miał wbudowane funkcje do przemieszczania się, obracania, skalowania i ogólnie wszystko co wyświetlany obiekt mógłby posiadać. Następnie była klasa Pawn (pionek). Jest to pierwszy typ klasy, który mógł być w jakiś sposób sterowany. Można było do niego przypiąć np. kontroler sztucznej inteligencji i już mógł się poruszać. Właśnie za jakiekolwiek poruszanie się, sztuczną inteligencję czy coś podobnego był odpowiedzialny kontroler . Następnie po Pawnie mieliśmy kolejną klasę: Character . Jest to już klasa, która odpowiadała jakiejkolwiek postaci. Miała wbudowane wiele funkcji np. skok czy specjalnie stworzony kontroler, gdzie ustawialiśmy np. prędkość poruszania się, wysokość skoku itp. Oczywiście poza tym w UE4 są jeszcze inne elementy hierarchii, jednak na ten moment nie są one dla nas ważne. Takie skonstruowanie klas pozwala na bardzo szybkie rozbudowywanie kodu, o czym przekonałem się wielokrotnie. Niedawno nawet napisałem prostą grę w C# dzięki tej technice:

Zombie Game in C# WinForms

Wiem, że WinFormsy do gier to się nie nadają ale z C# jestem średniakiem. Mniejsza z tym. Ta hierarchia klas jest genialna i właśnie tym się teraz zajmiemy. Zaprogramujemy sobie klasę Actor i Pawn (klasę Character pominiemy, gdyż rozszerzanie funkcjonalności Pawna „na zapas” nie ma teraz sensu), do nich napiszemy kontrolery a na końcu do tego dodamy jeszcze scenę (w naszym przypadku będzie to Level).

3.1. Aktor

Aktor to klasa bazowa dla wszystkich elementów, które możemy wyświetlić na scenie. Będzie składała się ona z jego pozycji (u mnie wzorem UE4 nazwę ją lokacją ), rotacji oraz metod (poza oczywiście „getterami” i „setterami” pozycji i rotacji):

  • virtual void Update(const float &deltaTime) – metoda wykonywana co klatkę gry. Domyślnie będzie pusta. Będzie dawała pochodnym klasom pole do popisu.
  • virtual void Draw() = 0 – metoda czysto wirtualna , która odpowiada za wyświetlenie aktora na scenie. Aktor domyślnie nie ma nic do wyświetlenia więc zostawiamy ją dla potomnych. Dodatkowo, ustawienie metody tej czysto wirtualną sprawia, że nie możemy tworzyć obiektów dokładnie tej klasy (możemy dopiero pochodnych, które zdefiniują tą funkcję).

Przecież do operacji na lokacji w świecie 2D potrzebujemy wektorów dwuwymiarowych, skąd je weźmiemy?

Teoretycznie moglibyśmy używać wektorów z SFMLa jednak mam lepsze rozwiązanie. Nie zamierzam marnować Waszego czasu w tym artykule na pisanie klasy vector2d bo gdybym miał tu jeszcze wszystko objaśniać to artykuł byłby kilka razy dłuższy. Zamiast tego na końcu tego artykułu wrzucę link do napisanych już przeze mnie wektorów 2D i 3D, darmowo dostępnych dla każdego (czyli „róbta co chceta”). Jest tam praktycznie wszystko czego Ci potrzeba a zaoszczędzisz czas. Kod pochodzi z mojego silnika o nazwię GRIM, dlatego całość umieszczona jest w przestrzeni nazw grim.

Przejdźmy zatem do programowania. Tym razem, jako że Aktor będzie klasą przygotowującą swego rodzaju interfejs do późniejszego programowania klas pochodnych (nie mylić z interfejsem z C# czy Java) zostanie nazwana class IActor . Umieszczę ją w całości w jednym pliku Actor.hpp ze względu na to, że (według mnie) w tym przypadku tworzenie osobnego pliku Actor.cpp na definicję, których byłoby 5 linijek jest niepotrzebne.

Tak naprawdę już wszystko zostało wyjaśnione, dodatkowo warto popatrzeć na komentarze w kodzie. Dodane zostały proste metody pomocnicze Move() i Rotate() i w sumie to wszystko. Warto też wspomnieć dlaczego do funkcji Update() przekazujemy const float &deltaTime . Jest to nic innego jak czas w jakim wykonała się ostatnia klatka liczony w sekundach. Dlaczego go przekazujemy? Jest to podstawa programowania gier – większość rzeczy jest zależne od czasu. Na przykład poruszamy się 200 kilometrów na godzinę albo 300 pikseli na sekundę , dlatego żeby coś takiego osiągnąć musimy znać czas ostatniej klatki. Do samych obliczeń przejdziemy na końcu artykułu. Warto zwrócić tutaj uwagę na wektory – oswoić się z nimi bo bardzo one się przydają!

Teraz przyszedł czas aby wykorzystać aktora do klasy Pawn .

3.2. Pionek (Pawn)

Klasa pionka to podstawowa klasa, którą można kontrolować. Jest również możliwa do wyświetlenia na scenie przez to, że dziedziczy z klasy aktora. Ponownie jest to klasa niejako tworząca interfejs dla późniejszych klas a sama nie może zostać bezpośrednio wykorzystana więc nazwiemy sobie ją class IPawn (I od Interface). Tym razem dodatkowo musimy stworzyć sobie dla niej kontroler. Zaczniemy jednak od przygotowania samego kontrolera dla pionka class IPawnController

  • Dodaje pole IPawn *m_owner , które odpowiada „właścicielowi” tego kontrolera.
  • Tworzę podstawowy konstruktor, destruktor.
  • Dodaje metodę Possess(IPawn *owner) która ustawia właściciela kontrolera, jeśli kontroler wcześniej nie został już przypisany.
  • Dodaję metodę Update(const float &deltaTime) = 0 , która będzie wywoływana przez pionka w jego metodzie o tej samej nazwie. Jak widać jest to metoda czysto wirtualna, więc musi być zdefiniowana w pochodnych klasach, żeby w ogóle móc stworzyć kontroler.
  • Dodaję pomocniczą metodę IsPossessed() const , która zwraca po prostu czy m_owner != nullptr .

Klasa kontrolera wygląda teraz tak:

Zanim przejdziemy do definicji tych metod, wypadałoby jeszcze zaprogramować klasę pionka. O to jak to widzę:
Wiedząc, że pionek to tak naprawdę IActor rozszerzony o kontroler:

  • Tworze pole, które będzie wskaźnikiem na kontroler, nazwijmy je m_controller;
  • Tworze konstruktor z wskaźnikiem na kontroler jako parametr explicit IPawn(IPawnController *controller); Dodatkowo, jeśli nie podano pustego wskaźnika na kontroler to przypisuję ten kontroler metodą Possess() (dodatkowo trzeba sprawdzić czy przypadkiem kontroler nie miał już poprzedniego właściciela, jeśli miał to funkcja Possess() zwróci false ).
  • W destruktorze jeśli kontroler został ustawiony usuwam go z pamięci delete m_controller;
  • Przeładowuje funkcję IActor::Update(const float &deltaTime) tak, żeby wywołać metodę IPawnController::Update(const float &deltaTime)
  • Tworzę metodę, którą będę mógł ustalić nowy kontroler dla pionka ResetController(IPawnController *newController) . Stary idzie w zapomnienie delete m_controller a nowy przypisuję do m_controller .

Uwaga! Tutaj też trzeba sprawdzić, czy przypadkiem kontroler nie miał już poprzedniego właściciela. Przewidujemy też, że programista może chcieć całkowicie usunąć kontroler i napisze ResetController(nullptr);

W tym momencie zalecam przystopować na chwilę z czytaniem i dać sobie chwilę na zrozumienie co tak właściwie robimy: tworzymy sobie niejako interfejs pod późniejsze programowanie. Do tego używamy klasy aktora, który będzie podstawową klasą do wyświetlania czegokolwiek a po nim będzie pionek, którym będzie sterował jego kontroler. Kontroler do sterowania także potrzebuje funkcji Update(const float &deltaTime) . Metodą Possess(IPawn *owner) z klasy kontrolera możemy sprawić, że kontroler dostanie właściciela ale tylko gdy jeszcze tego właściciela nie miał!

Odetchnąłeś? W takim razie ruszamy dalej. Oto jak według powyższych założeń będzie wyglądała klasa IPawn :

Wszystko zostało wyjaśnione powyżej. Spokojnie, poświęć trochę czasu na analizowanie kodu jeśli musisz bo zaraz przejdziemy do definicji.
Jako, że definicja klasy IPawnController jest „lżejsza” to zaczniemy od niej. Powinna ona wyglądać w ten sposób:

Tak naprawdę jedynym miejscem, na które warto zwrócić uwagę jest metoda Possess() . Prościutka metoda, która sprawdza czy przypadkiem kontroler nie jest już wykorzystywany przez jakiegoś pionka. Jeśli nie to swobodnie można go przypisać do pionka owner i zwrócić true . Jeśli jednak kontroler już był przypisany to nic nie robimy tylko zwracamy false . Wartość zwracana przez tą funkcję oznajmia czy udało się przypisać kontroler czy nie.

Teraz przejdziemy do definicji klasy pionka. Według założeń powinna wyglądać ona tak:

Zacznijmy analizę kodu od samej góry – konstruktor : mamy tam warunek if (controller && controller-> Possess ( this )) . Oznacza to tyle co „ jeśli podany kontroler jest inny od nullptr (wskaźnik zerowy) oraz uda się go przypisać do siebie ( metoda Possess() zwróci true ) ” to następnie ustaw sobie ten kontroler jako własny m_controller = controller . Jest to zabezpieczenie przed tym, gdyby ktoś do konstruktora podał bezmyślnie cudzy kontroler. Jeśli tak się stanie to konstruktor go zignoruje i wciąż nie będzie posiadał kontrolera.

Następnie jest tam destruktor – prościzna, po prostu usuwamy kontroler jeśli istnieje.

Kolejnym elementem jest funkcja ResetController : na samym początku sprawdzamy czy przypadkiem ktoś przez pomyłkę nie podał naszego kontrolera jako argument. Sprawdzamy to dlatego, że zaraz w tym warunku usuwamy stary kontroler (gdyby stary kontroler był tym nowym to usunęlibyśmy również nowy, a tego nie chcemy). Usuwamy stary kontroler. Następnie to samo co w konstruktorze – sprawdzamy czy uda się przypisać kontroler do siebie, jeśli tak to spoko (ustawiamy m_controller = controller ). Jeśli się nie udało to ustawiamy go na nullptr . Ende.

Teraz znowu prościzna – metoda Update(const float &deltaTime) , która po prostu wywołuje tą samą metodę w kontrolerze (pod warunkiem, że on istnieje).

3.3. Poziom (scena)

Tak jak obiecałem, żeby jeszcze łatwiej zarządzać aktorami na scenie (i wszystkimi obiektami pochodnymi od aktora) stworzymy sobie klasę sceny.
Więc chciałbym aby nasz poziom (scena) zapewniał takie funkcjonalności:

  • Przechowywanie aktorów z tej sceny w jakimś kontenerze std::vector<IActor*> m_actors;
  • Możliwość dodawania aktorów do poziomu (+ zabezpieczenie przed dodaniem tego samego aktora kilka razy) bool CLevel::Add(IActor *actor);
  • Możliwość usunięcia aktora z poziomu bool CLevel::Remove(IActor *actor);
  • Możliwość sprawdzenia czy aktor jest na scenie bool CLevel::Exists(IActor *actor) const;
  • Usunięcie wszystkich aktorów ze sceny.
  • Sprawdzenia ilu aktorów jest na scenie.
  • Wywołania na wszystkich aktorach metody Update oraz Draw (uaktualnienie i wyświetlenie)

Tak więc po zaprogramowaniu tego kod Level.hpp powinien wyglądać tak:

Chwila na przeanalizowanie tego kodu i przechodzimy do definicji. Możecie zauważyć podobieństwo do menedżera tekstur. Prawdę mówiąc nasz Level to menedżer aktorów z kilkoma bajerami jak wyświetlanie i uaktualnianie. Kod źródłowy też jest banalny:

Więcej tutaj komentarzy niż kodu 🙂

Gdzieś teraz trzeba wykorzystać tą klasę. Umieścimy ją po prostu w klasie gry. Nie jest to singleton, poziomów może być wiele ale żeby już nie przedłużać stworzymy sobie pojedynczy obiekt. Obok wszystkich pól klasy CGame umieszczam CLevel *m_currentLevel; Dopisuję do niego „getter” inline CLevel *GetCurrentLevel() { return m_currentLevel; } Pamiętamy o inicjalizacji wskaźnika w konstruktorze. Tworzę nowy poziom. Pamiętamy o destruktorze – if(m_currentLevel) delete m_currentLevel;

3.3.1. Mierzenie czasu klatki

SFML dostarcza bardzo ładną pomocniczą klasę sf::Clock do mierzenia czasu. Tworzę sobie obiekt tej klasy wewnątrz metody CGame::Run() , nazwę go GameClock . Razem z nim przydałaby się jakaś zmienna, w której mógłbym zapisywać czas ostatniej klatki, nazwę ją DeltaTime . Należy wykonać dwa pomiary czasu – na początku głównej pętli (początek klatki) oraz na końcu, już po wyświetleniu wszystkiego (koniec klatki).

I have a CLevel, I have a DeltaTime… ughhh

Mamy już wszystko do wbicia ostatniego gwoździa w tworzenie sceny. Jedyne co nam pozostało to w pętli umieścić update’owanie i wyświetlanie całej sceny. W tym momencie metoda CGame::Run() wygląda tak:

Czas na króciutkie podsumowanie: wiecie co dzięki stworzeniu takiej sceny, aktorów, pionków i kontrolerów uzyskaliśmy?

  1. Całość oparta jest na klasach
  2. Aby np. dodać dwóch graczy mogących się poruszać po mapie wystarczy:
    1. stworzyć klasę gracza dziedziczącą od klasy pionka, dodać im sprajta i ustawić teksture
    2. stworzyć dwa kontrolery dla gracza – jeden poruszany np. WSAD, drugi poruszany strzałkami
    3. dodać dwóch aktorów na scenę: m_currentLevel->Add(firstPlayer) i m_currentLevel->Add(secondPlayer) i voila!
  3. Bardzo łatwe zarządzanie elementami sceny
  4. Bardzo szybko można dodawać nowe funkcjonalności (np. różne pociski).

4. Zakończenie

Jeśli dotrwałeś do tego momentu to szczerze gratuluję. Ten artykuł nie należał do najprostszych ani najkrótszych jednak myślę, że jest do ogarnięcia. ALE TO JESZCZE NIE WSZYSTKO 🙂 W tym momencie ponownie zachęcam do uzupełnienia jakichkolwiek braków, spróbowania jeszcze chwile posiedzieć nad kodem by jak najlepiej wszystko zrozumieć. Na koniec, by wszystko sobie utrwalić zachęcam do obejrzenia „krótkiego” filmu, podczas którego bazując na kodzie z tego artykułu, piszę grę typu Bomberman dla dwóch graczy. Myślę, że to będzie świetne podsumowanie tego materiału.

PS. Zanim przejdziesz do poniższego filmu polecam zrobić sobie kawę i upewnić się, że masz sporo czasu 😉

4.1 FAQ:

  • Gdzie znajdę kod z tego artykułu?
    Kod zamieściłem na tym repozytorium.
  • Gdzie znajdują się te vectory itp?
    Są razem z plikami tego projektu w repozytorium – oto one:

    • Include/Math.hpp – dołącza poniższe pliki:
    • Include/Math/Declarations.hpp
    • Include/Math/Vector2.hpp
    • Include/Math/Vector3.hpp
  • Czemu tak dużo kodu?
    Uważam, że temat nie jest prosty i żeby dobrze go zrozumieć należy mieć możliwość wglądu jak ktoś to pisze, żeby początkowo móc się wzorować i stopniowo poznawać sposób działania. Oprócz tego mam nadzieję, że nie byłem aż tak okropnym nauczycielem.
  • Dlaczego nie zacząłeś od razu od hierarchii klas z UE4?
    Ten materiał dotyczy nie tylko tego. Ma on na celu również pokazać dobre praktyki co do tworzenia gier. Między innymi dlatego pokazałem jak stworzyć menedżer tekstur, by zapobiec popularnym wśród początkujących gamedeveloperów błędom jak np. ładowanie tekstury w pętli. Oprócz tego starałem się zostawić jak najmniej niedociągnięć w kodzie (oznaczać poprawnymi identyfikatorami wszystkie elementy jak np. final, override czy inline).
  • Dlaczego nie używałeś inteligentnych wskaźników?
    Jestem przekonany, że jeśli piszę artykuł to mam za zadanie dokładnie wytłumaczyć w jaki sposób zarządzam pamięcią w swoim kodzie. Dotyczy to alokowania i zwalniania pamięci i lepiej gdyby podczas czytania artykułu czytelnik mógł spojrzeć w kod i dostrzec dokładnie gdzie zostaje zwolniona pamięć. Inteligentne wskaźniki to bardzo dobry „wynalazek” i jeśli ktoś pisze kod dla siebie to polecam jak najbardziej ich używać.

Paweł Syska

Zajmuję się programowaniem w C++ od początku 2009 roku, oprócz tego poznałem w tym czasie kilka innych języków, jednak wciąż pozostaje wierny temu pierwszemu ;)

Zobacz wszystkie posty tego autora →

Komentarze

  • Etiaro

    Mam pytanie : jak poprawnie zrobić metodę draw dla obiektu dziedziczacego po IActor? Nigdzie w kodzie tego nie ująłeś,a nie jestem w stanie sobie z tym poradzić

    • Paweł Syska

      Wiedziałbyś gdybyś chociaż przejrzał film, który nagrałem. Dajmy na to, że masz klasę Player dziedziczącą po IActor.
      Dodajesz do klasy Player jego sprajta (sf::Sprite m_sprite na przykład).
      Następnie przeładowujesz metodę Draw:
      virtual void Draw() override
      {
      CGame::Instance().GetWindow().draw(m_sprite);
      }
      Jeśli masz już tego sprajta dobrze by było przeładować metodę SetLocation, żeby ustawiała ona pozycję również Twojemu sprajtowi. Tak jak na filmie widać, nie można zapomnieć w tym, żeby wywołać metodę SetLocation z klasy nadrzędnej (ona też coś robi. Jeśli przeładujesz po prostu metodę to wszystko co robiła ona w klasie nadrzędnej zostanie pominięte)
      virtual void SetLocation(const grim::Vector2 &location)
      {
      IActor::SetLocation(location); // wywołujemy metodę klasy bazowej
      // tutaj robimy to co dodatkowo chcemy
      m_sprite.setPosition(location.x, location.y);
      }