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

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.

download

Code ON!


  • Szymon

    Bawię się w C++ już od dwóch lat, a ten projekt jest bardzo ciekawy. Wiele nowego się z niego nauczyłem 😉 Ale nurtuje mnie jedna sprawa. Dlaczego mapa nie wczytuje się z poziomu Visual Studio. Jak włączę sam .exe z folderu to wszystko działa 😀
    I jeszcze pytanie: Kurs ma pan zamiar dodawać na bieżąca np raz w tygodniu czy kiedy tylko będzie czas? 🙂 Pozdrawiam

    • Może jest to spowodowane faktem, że w folderze nie ma pliku poziomu, u mnie to wygląda następująco NazwaProjektu->NazwaProjektu->data->plikPoziomu.

      Co do drugiego pytania to z doświadczenia wiem, że dodawanie co tydzień kolejnych części jest trudne w realizacji i tak jak pisałem w spisie treści: kolejnych części należy się spodziewać co 2-6 tygodni, czyli wtedy kiedy pozwoli mi czas.

      Pozdrawiam

    • Kamil

      Przy podawaniu ścieżki względnej do metod, uruchomienie pliku z poziomu visuala spowoduje szukania plików z miejsca gdzie jest kod źródłowy, a uruchomienie bezpośrednio z pliku exe spowoduje szukanie plików z miejsca gdzie jest plik exe.

  • Programmer

    Można mniej więcej wiedzieć w jaki sposób będziesz rysował tylko widoczne NPCty? Bo one się pewnie będą jakoś poruszać etc. i tu sprawa wygląda trochę inaczej.

    • Hmm, wbrew pozorom odpowiedź na pytanie nie jest taka oczywista przy założeniu że będą się poruszać, czego w zasadzie nie planowałem, ponieważ system chciałem przenieść na oddzielny ekran i walka by polegała na jakiejś minigierce.

  • max

    impossible with Xcode :/ I have just one square in the top left hand corner

  • Święty

    Tak się zastanawiam – co jest poprawniejsze/bardziej optymalne? Jeżeli mamy tablice tekstur i chcemy teraz wyświetlić mapę używając tych tekstur, to czy lepiej jest przechowywać wszystkie sprity w tablicy (tak jak powyżej) czy trzymać jednego sprita odpowiadającego każdej teksturze?

    z moich wniosków – przy pierwszym sposobie – jeżeli mamy np 40 traw to zajmujemy pamięć dla 40 takich samych spritów – pewnie mało zabierają, ale zawsze coś.

    przy drugim sposobie – optymalnie względem ilość wykorzystanego miejsca, ale rezygnujemy z takich rzeczy jak: sprawdzanie czy myszka znajduje się nad spritem

    które rozwiązanie jest bardziej właściwe?

    • A możesz powiedzie w jaki sposób byś rysował mapę? Jak rozumiem to chciałbyś przemnażać numer tekstury i kopiować ją na określoną pozycję? W tym przypadku chyba jest lepiej zejść jeszcze niżej do: openGL’a

      • Święty

        pierwszy sposób:

        tak jak powyżej, tablica spritów odpowiadająca polom na mapie – każdego sprita rysujemy osobno.

        drugi sposób:

        tablica pól odpowiadająca każdemu polu na mapie, posiadająca informację o typie pola
        tablica spritów gdzie do każdego sprita jest wczytana konkretna tekstura
        podczas rysowania:
        sprawdzamy typ pola
        sprawdzamy współrzędne pola na jakim się znajdujemy, by spritowi odpowiadającemu typowi pola zmienić współrzędne na aktualne pole
        rysujemy tego sprita
        postępujemy tak dla każdego pola

        • Zyskasz na zarezerwowanej pamięci na Sprite’y, bo giną ci transformy, wskaźniki na teksturę. Przy mapie kafelkowej ma to nawet jakiś sens, ale nie powiem ci jaki jest skok wydajnościowy bo nie wiem czy Sprite nie ma w sobie jakiejś „magii”, ale ogólnie powinieneś uzyskać lepszy rezultat