[Piszemy grę RPG] #2 Poziomy (mapy)

Uwaga! Poradnik to czeka się kontynuacji. Więcej informacji [tutaj].

Wstęp


Ta lekcja mogłaby się wydać zbyt trywialna i nie warta uwagi, lecz aby nie było zbyt łatwo to  oprócz wczytywania poziomów zaimplementujemy sobie również wczytywanie poziomów o dowolnych rozmiarach, a także stworzymy przewijaną mapę, która będzie wyświetlała jedynie potrzebną nam ilość kafli.

Co więcej sprawimy, że nasza gra będzie nieco bardziej różnorodna, przy wykorzystaniu tych samych kafli. Będę korzystał w tej lekcji z mojego własnego tilesetu (jest dostępny pod licencją CC0).

tileset

 

Klasa „Game” – pierwsza wersja


Skoro wiemy już co zrobimy podczas tej lekcji to może na początku warto napisać sobie zalążek klasy Game, którą będziemy rozwijać w miarę potrzeb. Jak skonfigurować projekt pisałem w innym poradniku.

Nie zrobimy tutaj absolutnie nic odkrywczego.

Posiadamy tutaj dokładnie to czego można się spodziewać po najprostszym szkielecie uruchamiającym naszą grę.

Jedyną nowością, czy też mnie zrozumiałą rzeczą po przejściu Piszemy grę w SFML’u, może być dodatkowy argument float delta , który jest skrótem od delta time. Co jak nietrudno się domyślić jest czasem pomiędzy renderowanymi klatkami, ten argument przyda nam się później przy poruszaniu obiektami.

Metoda start()  tak jak napisałem jest metodą z główną pętlą i to tutaj będziemy przechwytywali wszelkiego typu zdarzenia, np. wciśnięcia klawiszy.

W tym poradniku nie będziemy tworzyli sobie żadnego menu, ponieważ wyglądałoby analogicznie do tego znanego nam już z kursu Piszemy grę w SFML’u.

Aby wszystko było jasne jeszcze funkcja główna:

 

Klasa Level


Tak jak przy planowaniu, przed podjęciem dalszych prac warto się zastanowić jak chcemy aby wyglądały dalsze wersje tej klasy, z możliwością dalszego łatwego rozwoju.

Z racji, że chcemy móc tworzyć poziomy o dowolnych rozmiarach to musimy znać jej rozmiary (w kaflach). Przydałoby się znać także pozycję gracza na której ma rozpoczynać grę, tutaj warto zaznaczyć, że punktów startu może być wiele, ponieważ możemy stworzyć miasto w którym będzie wiele budynków do których można wejść, a po wyjściu chcielibyśmy aby nasza postać pojawiła się przy drzwiach budynku z którego wyszliśmy.

Dalej mamy jakieś specjalne obiekty, z którymi może wejść w interakcję gracz, tymi elementami będą właśnie nasze przejścia na inne mapy (teleporty), skrzynie, NPC i ogólnie co nam się tylko wymarzy.

Myślę, że system, który napiszemy powinien być dość łatwy w rozbudowie, teraz zastanówmy się jak może wyglądać mapa „od środka”. Schematycznie:

A nieco bardziej życiowy przykład:

Postanowiłem, że specjalne bloki będziemy przechowywali na samym końcu, w przypadku gdy nie damy żadnego punktu wejścia [Start] to ustawimy postać na kaflu (0,0).

Najwyższy czas na nieco więcej kodu, wszelkie dodatkowe uwagi poczynimy sobie przy oprogramowaniu tej klasy.

„Talk is cheap. Show me the code.” – Linus Torvalds

Na start zobaczmy sobie definicje wszystkich elementów jakie będą nam potrzebne przy pisaniu tej klasy.

Nasza klasa posiada oczywiście konstruktor, który będzie przygotowywał wartości paru obiektów, dalej posiadamy znaną nam już konwencję przechowywania typu kafli przy pomocy enum , to jest nic więcej jak reprezentacje typów kafli dalej przy pomocy cyfr, tylko że teraz w bardziej przyjaznej dla nas formie. Później indeksy enuma będą oznaczały numer indeksu tablicy tekstury, którą należy wyświetlić.

Kolejnym dość ważnym dla nas elementem jest struktura struct Tile , która reprezentuje logikę każdego kafelka.

Pole TileType odpowiada rodzajowi kafelka, który należy narysować, interaction z kolei opisuje rodzaj interakcji do odtworzeniu po wejściu na ten kafelek, kolejne flagi odpowiadają za ustawienie tego czy obiekt ma generować kolizje oraz czy gracz może wejść w interakcję z tym kafelkiem.

Dalej mamy dość oczywiste elementy, bo: tablicę reprezentującą całą planszę, domyślne miejsce gdzie ma się spawnować gracz oraz szerokość i wysokość poziomu.

Chyba nic nadzwyczajnego nie znajdziemy w konstruktorze.

Ta metoda powstała w celu naszej wygody i czystości zapisu, mianowicie dostarcza ona przy wczytywaniu poziomów standardowych (predefiniowanych) własności dla danego typu kafla.

Wreszcie doszliśmy do długo wyczekiwanego wczytywania poziomów i pierwsza część powinna wyglądać znajomo do tego co robiliśmy w Piszemy grę w SFML’u, jednak tam mieliśmy poziom zawsze o tych samych wymiarach.

Przeżyjmy ten kod razem (liczby w nawiasach to nr’y linii):

  1. Po pomyślnym otworzeniu pliku pobieramy wymiary poziomu w kaflach [10].
  2. Jeżeli dane były poprawne to przystępujemy do powiększania tablicy [18], tutaj należy zwrócić uwagę na fakt, że po tablicy będziemy poruszali się odwrotnie niż do tego jesteśmy przyzwyczajeni, np. jeżeli chcemy dostać się do kafla o pozycji (2,3) musimy napisać map[3][2] . Dlaczego odwróciliśmy tablicę w ten sposób? Po pierwsze jest to podyktowane naszą wygodą przy wczytywaniu plików oraz po drugie ten sposób jest także wygodniejszy przy rysowaniu.
  3. Po rozszerzeniu tablicy przystępujemy do właściwego wczytania kafelków [25], dla ułatwienia w przyjęcia nowej konwencji dostawania się do kafla o określonych współrzędnych w pętli używamy zmiennych o nazwach x,y zamiast zwyczajowych i,j.
    Samo wczytywanie nie jest jakoś specjalne wymyślne, używa ono naszej metody getTile, gdzie jako argument przesyłamy wartość dopiero wczytanego nr’u kafla.
  4. [36] Tutaj zaczynają dziać się ciekawe rzeczy, ponieważ w tej części kodu zaczynamy wczytywać specjalne eventy, a co za tym idzie możemy nadawać naszym kaflom specjalnych właściwości. Także tutaj znamy defaultowe miejsce spawnu gracza, w mojej konwencji spawn nie będzie nidy zdefiniowany na pozycji (0,0) stąd warunek w linii [49] . Obecnie nie mamy za wiele eventów, bo tak naprawdę obecnie zrobiliśmy jedynie [Start], który odpowiada za miejsce startu, a reszta eventów standardowo będzie interaktywna.
rpg-loadinglevel
Schemat kolejności wczytywania kafli z pliku

 

 

Rysowanie mapy v1


Nadszedł wyczekiwany moment, czyli czas na narysowanie mapy w naszym oknie i skorzystamy tutaj z idei którą przedstawiłem w swoim artykule: Wyświetlanie przewijanej mapy.

W skrócie: chodzi o to aby nie tworzyć tablicy sprite’ów o tych samych wymiarach co rozmiar całego poziomu i następnie to wszystko wyświetlać jednocześnie (pomińmy fakt, że tablica nie może zajmować więcej niż 4GB, także przy tablicy sprite’ów nasze poziomy byłyby dość ograniczone rozmiarowo).

schemat_wczytywania
Schemat wczytywanych kafli

Idea polega na stworzeniu i wyświetlaniu tablicy o wymiarach jedynie nieznacznie przekraczającej wymiary okna, bo i tak gracz więcej nie zobaczy, dla przykładu jeżeli na scenie jesteśmy w stanie wyświetlić w sumie 220 kafli, a nasz poziom składa się z 1024 kafelków oszczędność jest naprawdę spora (dodam, że te liczby są wzięte z dokładnie tej lekcji).

Znamy idee, a więc czas zabrać się do pracy (będziemy pracowali na klasie Game).

Klasa wzbogaciła się o wiele elementów, będziemy przechodzili przez wszystkie metody wg kolejności powyżej, jednak na razie omówmy sobie „zmienne”:

  • View view  to nic innego jak odpowiednik kamery, w innych silnikach mógłby być nazwany po prostu Camera;
  • int WIDTH,HEIGHT  to rzeczywiste rozmiary tablic do wyświetlania sprite’ów, są to wartości powiększone o 2 od ilości kafli jakie się mieszczą na ekranie w poziomie/pionie, tak aby zachować dodatkowy margines na kafle, które może być widać jedynie częściowo;
  • Texture texture[Level::COUNT]  oczywiste;
  • Level level  obiekt, w którym przetrzymujemy warstwę „logiczną” poziomu;
  • vector<vector<Sprite>>  sprite tablica na wyświetlane kafelki o wymiarach WIDTH i HEIGHT;
  • Vector2f player  tymczasowa reprezentacja gracza w formie punktu.

W konstruktorze mamy trochę nowości, przyjrzyjmy im się nieco.

Do naszej klasy doszły nam dwa makra: SCRN_WIDTH i SCRN_HEIGHT, które wyrażają wymiary okna w pikselach. Następnie mamy zadeklarowaną wartość wielkości kafla TILE_SIZE, także w pikselach. Zastosowanie tego powinno nieco nam przyspieszyć ewentualne późniejsze zmiany, gdyby nas naszło na np. 32 pikselowe kafelki.

Zaraz po stworzeniu okna tworzymy naszą kamerę o dokładnie tych samych wymiarach co okno i tymczasowo ustawiamy na środku sceny.

Kolejnym krokiem jest wczytanie kafli z pliku do pamięci, nie dzieje się tutaj nic wartego uwagi.

Jak dalej możemy zobaczyć wymiary WIDTH i HEIGHT to ilość kafli jaka mieści się na ekranie powiększona o 2 z wcześniej wymienionych powodów.

Dalej tworzymy tablicę sprite’ów której domyślne wartości to pierwsza tekstura. Ustawiamy także gracza na środkowych kaflach oraz ustawiamy przykładową mapę.

Zanim przejdziemy do void setMap(...) , zobaczmy co się dzieje w metodzie start(), w której mamy tymczasowe poruszanie się „postaci”.

W zasadzie doszło nam jedynie tymczasowe sterowanie oraz nowa zagadkowa metoda: updateMap() do której później przejdziemy.

Metoda update() dalej pozostaje pusta, lecz w końcu możemy coś narysować więc możemy napisać finalną wersję metody draw().

Czas na ustawienie naszej mapy, oprócz samego wczytania mapy do pamięci w tej funkcji jesteśmy w stanie zareagować na nieudane wczytanie poziomu, później będziemy tutaj ustawiać gracza na odpowiedniej pozycji startowej.

Skoro potrafimy ustawić mapę, to przydałoby się ją ustawić na odpowiedniej pozycji.

Omówię tylko część dla ustawiania kamery w poziomie, ponieważ część dla pionu jest analogiczna. Co ważne: ta funkcja wymaga poziomu, który jest większy wymiarami niż ilość kafli w pionie i poziomie, jeżeli poziom jest mniejszy to wystarczy dużo mniej skomplikowana funkcja.

Spróbujmy przeżyć to co się tutaj dzieje:

  • view.setCenter(player)  domyślnie próbujemy ustawić kamerę tak aby gracz był na środku, lecz jak wiadomo nie zawsze jest to możliwe, chyba że chcemy widzieć kamerę poza tym co jest widoczne na mapie.
  • Vector2f min = ...  wyliczamy wartości minimalne, tzn „dolne” krawędzie naszej kamery.
  • int leftBorder = min.x / TILE_SIZE  numer kafla, który będzie rysowany jako pierwszy, jest to numer kafla w mapie klasy Level.
  • int rightBorder = leftBorder + WIDTH - 2  analogicznie, jak wyżej, tylko że pamiętamy o uwzględnieniu faktu, że to co niewidoczne będzie rysowane 2 kafle wcześniej, stąd to odjęcie dwójki.

To tyle jeżeli chodzi o przygotowane do ciekawszego elementu tej funkcji. Ogólnie możemy wyróżnić 3 podstawowe warunki pozycjonowania kamery:

  1. Kamera wychodzi lewą stroną poza poziom.
  2. Kamera nie wychodzi żadną krawędzią poza ekran.
  3. Kamera wychodzi prawą stroną poza ekran.
Przypadek 1
Przypadek 1

Co z tego wynika? W (1) należy kamerę przesunąć w prawo oraz trzeba przesunąć ją w taki sposób aby naszej miejsce na bufor także zostało przesunięte, a zatem po prawej stronie poza ekranem będą 2 kafle.

W (2) po lewej i po prawe stronie będzie 1 niewidoczny kafel. W (3) analogicznie jak w (1).

Przypadek 2
Przypadek 2

Pierwszy warunek jest dla warunku (1) i jeżeli lewa krawędzi kamery jest wysunięta poza ekran to przesuwamy ją z powrotem o tą samą wartość.

Przypadek (2) jest jednocześnie najczęściej występującym, a zatem wysunięcie tego co ma być w pamięci musi się zaczynać o 1 kafelek wcześniej niż kafel na którym zaczyna się lewa krawędź, stąd linia min.x -= TILE_SIZE; .

Przypadek (3) jest najciekawszy ponieważ wymaga wielu obliczeń, jest niby analogiczny do (1), jednak jak tam najmniejszy kafel (poziomu) był zawsze równy 0, tak tutaj może być różny.

Ostatni warunek zapobiega jedynie prześlizgiwaniu się kamery o 2 kafle.

Dalej mam analogiczną sytuację dla drugiej osi, a także zwykłe ustawianie kafelków.

 

Podsumowanie


Gratuluje dotrwania do końca tej lekcji, w której napisaliśmy większość klasy Level (kod oraz plik .exe są dostępne do pobrania w paczce poniżej) w kolejnej lekcji zajmiemy się klasą Player.

Jeżeli masz jakieś pytania, znalazłeś błąd, czy może chcesz po prostu podzielić się swoją opinią to zapraszam do systemu komentarzy.

 

Code ON!