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:
- Jak poprawnie rozpocząć projekt i jakich tendencji powinniśmy się wystrzegać?
- Jak uporządkować główne dane gry i jak nimi zarządzać?
- Jak ułatwić sobie wczytywanie tekstur poprzez menedżer tekstur?
- Czym jest i z czego składa się scena i jak dzięki niej łatwo zarządzać elementami w grze?
- 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łemdelete (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ądtextureData.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 metodaUnload()
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 elementemtextureIt
jest iterator. W skrócie, jeśli mamyiterator
, 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śmytextureIt->second
by odwołać się do wskaźnikasf::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 prostutextureIt->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:
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 czym_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 funkcjaPossess()
zwrócifalse
). -
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 zapomnieniedelete m_controller
a nowy przypisuję dom_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
orazDraw
(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?
- Całość oparta jest na klasach
-
Aby np. dodać dwóch graczy mogących się poruszać po mapie wystarczy:
- stworzyć klasę gracza dziedziczącą od klasy pionka, dodać im sprajta i ustawić teksture
- stworzyć dwa kontrolery dla gracza – jeden poruszany np. WSAD, drugi poruszany strzałkami
-
dodać dwóch aktorów na scenę:
m_currentLevel->Add(firstPlayer)
im_currentLevel->Add(secondPlayer)
i voila!
- Bardzo łatwe zarządzanie elementami sceny
- 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 ;)
Komentarze