Symulator pożaru lasu – tworzymy okienko

Czas czytania: 7 minut

W poprzedniej części stworzyliśmy „podstawy podstaw” dla naszego symulatora pożaru lasu. Napisaliśmy podstawowe klasy – Tree, który przechowuje właściwości pojedynczego drzewa/pożaru, ForestMap odpowiedzialny za przechowywanie informacji o stanie całej planszy oraz szereg klas pomocniczych takich jak Coordinates czy SingleCell. Postawimy kolejny mały krok. Stworzymy klasę, która będzie obsługiwała wyświetlanie drzew/pożarów na ekranie. Program będzie także możliwy do skompilowania, gdyż stworzymy funkcję main(). Program będzie już coś wyświetlał. Zapraszam do lektury 🙂

Wymagane biblioteki

Podstawowy interfejs graficzny zostanie stworzony w bibliotece SDL2. Wynika to z założeń, które poczyniliśmy w poprzedniej części. SDL2 jest dosyć prostą (ale nie najprostszą) biblioteką w obsłudze. Architektura SDL-a pasuje bardziej do języka C niż C++, co w naszym wypadku jest drobnym problemem. Dlaczego? Ponieważ będziemy musieli „opakować” funkcje SDL-owe w ładne i przyjemne w dalszym rozwoju klasy.

Instalacja SDL2

Biblioteka SDL2 domyślnie nie jest wbudowana w żadne środowisko programistyczne. Jeśli będziemy chcieli jej używać, musimy ją zainstalować. Proces różni się w zależności od IDE którego używamy. Na niektórych jest prościej, na innych trudniej. Ogólnie, instalacja sprowadza się do pobrania plików biblioteki ze strony SDL2. Jeśli chodzi o system operacyjny Windows, są do wyboru dwie wersje. Jeśli używasz Visual Studio, wybierasz wersję SDL2-devel-VC.zip. W przeciwnym wypadku pobierasz wersję pod MinGW. (SDL2-devel-2.0.9-mingw.zip)

Po pobraniu instalujesz bibliotekę w twoim IDE. Przyjrzymy się szczegółowo procesowi instalacji w środowisku Codeblocks. Wybierasz architekturę biblioteki (32 lub 64 bity). Wersja 32 bitowa znajduje się w folderze i686-w64-mingw32, a wersja 64 bitowa w folderze x86_64-w64-mingw32. Wybierasz do instalacji odpowiednią wersję. Jeśli nie wiesz, którą wybrać najbezpieczniej będzie zainstalować wersję 32-bitową. Następnie kopiujesz zawartość folderu lib do zawartości folderu lib twojego kompilatora (w przypadku C::B będzie to np.: folder C:\Program Files(x86)\Codeblocks\MinGW\lib). Zawartość folderu include kopiujesz do folderu include twojego kompilatora (C:\Program Files(x86)\Codeblocks\MinGW\include).

Ostatnim krokiem jest dołożenie odpowiednich opcji do linkera. W naszym wypadku będzie to tylko jedna opcja: -lSDL2.

(Jeśli będziesz potrzebował dokładniejszych instrukcji instalacji biblioteki SDL2, pisz. Z pewnością postaram się pomóc. Być może poświęcę instalacji tej biblioteki oddzielny artykuł).

Dobra. Zainstalowaliśmy SDL-a. Przykładowy program typu „Hello World” znajduje się tutaj: https://gist.github.com/fschr/92958222e35a823e738bb181fe045274. Jeśli chcesz przetestować, czy poprawnie skonfigurowałeś swoje środowisko, skopiuj ten kod do swojego środowiska programistycznego i spróbuj go skompilować. Wszystko się udało i nie ma żadnych błędów kompilacji, prawda? No to świetnie! Możemy zająć się tworzeniem naszego symulatora :).

logo sdl

SDL2 – tworzymy klasę SDLMain

Wg wstępnych założeń, całą obsługę biblioteki SDL załatwi jedna klasa, którą nazwiemy SDLMain. Będzie ona miała kilka metod. Utwórzmy plik nagłówkowy SDLMain.h. Zawrzemy w nim definicję naszej nowej klasy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifndef INCLUDE_SDLMAIN_H_
#define INCLUDE_SDLMAIN_H_
#include "APoint.h"
#include <memory>
#include <SDL2/SDL.h>
using std::unique_ptr;
class SDLMain {
public:
    void init();
    void clear();
    void drawPoint(int x,int y,PType type);
    void gameLoop();
    void setResolution(int width,int height);
private:
    unique_ptr<ForestMap> map;
    SDL_Window *win = nullptr;
    SDL_Renderer* renderer = nullptr;
    function<void(int,int,PType)> func;
};
#endif

Pliki nagłówkowe oraz ogólne porady

Zacznijmy od plików nagłówkowych. Na razie wiele tego nie ma. Includujemy tylko to, co będzie nam niezbędne. APoint.h, jak pamiętasz, przechowuje klasy które utworzyliśmy poprzednim razem. Memory udostępnia nam wskaźniki inteligentne. W linijce 12 pojawia się upragniony SDL.

W linijce 6 używamy dyrektywy „using”. Dlaczego? Aby za każdym razem nie pisać std przed definicją wskaźnika unique_ptr. Moglibyśmy równie dobrze napisać: using namespace std; Obecnie nic by to nie zmieniło. Ale przecież program się rozwija. Możemy dołączyć biblioteki, które będą miały klasy o nazwach takich samych jak te, które są np.: w bibliotece standardowej std. Wtedy mielibyśmy wieloznaczność i w konsekwencji poważne problemy. To jest naprawdę dobra praktyka programistyczna przy większych projektach.

SDLMain – sedno sprawy – skladowe prywatne

Docieramy do linijki 7, w której zaczyna się definicja nowej klasy. Najpierw zastanówmy się, czym konkretnie zajmować się będzie klasa SDLMain?

SDLMain będzie odpowiedzialne za stworzenie okna, w którym przeprowadzimy symulację. Klasa ta będzie zarządzała okresem życia tego okna oraz wszystkim tym, co będzie w nim rysowane. Nie będziemy na razie rozważali obsługi klawiatury ani innych urządzeń wejścia/wyjścia. W tej wersji postaramy się tylko o to, aby program wyświetlił nam wszystkie punkty, które dodamy na sztywno do mapy.

Co wynika z powyższej analizy? Co będzie nam potrzebne? Z pewnością jakieś zmienne, których SDL używa do zarządzania oknem oraz bazgraniem po nim. Spójrz na linijki: 16 i 17. To właśnie te zmienne. Domyślnie są wynullowane, abyśmy nie mieli żadnych niespodzianek.

Skoro SDL2Main ma rysować punkty zawarte w ForestMap, to musi posiadać wskaźnik do tejże klasy. Wskaźnik typu unique_ptr jest zawarty w linijce 15. Dlaczego unique? Ponieważ w tym momencie zakładamy, że SDLMain będzie jedynym właścicielem ForestMap. Czas życia ForestMap także będzie powiązany z SDLMain. To jest logiczne. Przecież nie będziemy przechowywali współrzędnych drzew i pożarów po tym jak użytkownik zażyczy sobie, aby program został zamknięty, prawda?

W sekcji elementów prywatnych została tajemnicza linijka 18. Mimo iż zapis wygląda dziwnie, nie jest to nic skomplikowanego. Jest to wskaźnik na funkcję niezwracającą nic i przyjmującą dwa argumenty typu int oraz jeden typu PType. Po co nam ten wskaźnik? Przypomnij sobie funkcję drawAll z poprzedniej części, a zrozumiesz 🙂

SDLMain – sedno sprawy – elementy publiczne

Za co będą odpowiadały poszczególne metody klasy SDLMain?

  • void init(); jak sama nazwa wskazuje, służy do inicjacji/inicjalizacji czegoś. To właśnie tu będziemy tworzyli obiekty oraz przypisywali im odpowiednie wartości. W metodzie init utworzymy także okienko.
  • void clear();Obraz, położenie oraz kolor punktów będzie się zmieniał. Wszak – pożar nie jest stacjonarny i może się przemieszczać. Wobec tego musimy co każdą klatkę aktualizować obraz. Aby zaktualizować to, co wyświetlamy w okienku musimy najpierw skasować to, co było w nim narysowane wcześniej. Za to będzie odpowiedzialna omawiana metoda.
  • void drawPoint(int x,int y,Ptype type);metoda rysująca drzewo o danym stanie (type) na podanych współrzędnych (x,y). Spójrz na definicję tej metody. Sygnatura jest całkowicie zgodna ze wskaźnikiem funkcji, która znajdowała się we właściwościach prywatnych klasy.
  • void gameLoop(); – metoda, która będzie uruchamiana przy starcie programu i w której będzie zawarte wszystko. Zacząwszy od przygotowania ekranu i narysowania na nim wszystkiego, co użytkownik ma widzieć, a zakończywszy na obsłudze zdarzeń wejścia/wyjścia (której na razie nie planujemy).
  • void setResolution(int width,int height) Chcemy, aby użytkownik mógł zmieniać zarówno rozmiar okna jak i rozmiar pojedynczych punktów. Na zrealizowanie tego pomysłu istnieje kilka sposobów. My będziemy bawili się rozdzielczością. Umożliwi nam ona wpływ na rozmiar pojedynczych punktów bez ingerencji w pozostałą część programu.

SDLMain – implementujemy

Skoro wiemy, jakie metody chcemy utworzyć, pora zająć się ich implementacją. Stwórz plik SDLMain.cpp. Nad implementacją poszczególnych metod będziemy zastanawiali się w takiej samej kolejności jak wyżej.

Init

10
11
12
13
14
15
void SDLMain::init() {
    win = SDL_CreateWindow("Symulator pożaru lasu",SDL_WINDOWPOS_UNDEFINED,SDL_WINDOWPOS_UNDEFINED,700,700,SDL_WINDOW_SHOWN);
    renderer = SDL_CreateRenderer(win,-1,SDL_RENDERER_ACCELERATED);
    func = bind(&SDLMain::drawPoint,this,placeholders::_1,placeholders::_2,placeholders::_3);
    map = make_unique<ForestMap>();
}

W metodzie init nie dzieje się nic szczególnego. Do zmiennej win przypisujemy wynik działania funkcji SDL_CreateWindow. Podobnie postępujemy ze zmienną renderer. Warta zwrócenia uwagi jest linijka 14. Ze „wskaźnikiem na funkcję” func „bindujemy” metodę drawPoint. Drugim argumentem bind jest this, gdyż bindujemy metodę z konkretnego obiektu. Jest nim ten, w którego wnętrzu obecnie się znajdujemy. Zastanawiające mogą być ostatnie 3 argumenty. Czym są te placeholdery? Za ich pomocą informujemy kompilator, że do bindowanej funkcji możemy przekazać trzy parametry. Wniosek z tego jest prosty: placeholderów zwykle powinno być tyle, ile argumentów przyjmuje funkcja.

Na samym końcu tworzymy ForestMapę.

clear

void SDLMain::clear() {
	SDL_SetRenderDrawColor(renderer,0,0,0,255);
	SDL_RenderClear(renderer);
}

Czyszczenie ekranu w bibliotece SDL to tylko dwie linijki kodu. W pierwszej ustawiamy kolor, na który chcemy zamalować ekran. SetRendererDrawColor przyjmuje jako argumenty: renderer oraz cztery liczby typu int. Pewnie domyślasz się, co one oznaczają. Pierwsze trzy to kolor zapisany w formacie RGB. Natomiast ostatnia to stopień przezroczystości.

Kolejna linijka tej metody to nic innego jak zrealizowanie planów założonych w poprzednim wierszu. SDL_RenderClear(renderer) wypełnia cały ekran kolorem, który przypisaliśmy rendererowi za pomocą funkcji SetRendererDrawColor.

drawPoint

Funkcja drawPoint jak już doskonale wiesz, odpowiada za rysowanie pojedynczego punktu. Jak będziemy to realizowali?

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void SDLMain::drawPoint(int x,int y,PType type) {
    switch(type) {
        case EXIST:
            SDL_SetRenderDrawColor(renderer,0,255,0,255);
        break;
        case FIRE:
            SDL_SetRenderDrawColor(renderer,255,0,0,255);
        break;
        case BURN:
            SDL_SetRenderDrawColor(renderer, 91, 46, 0, 255);
        break;
            return;
            break;
    }
    SDL_RenderDrawPoint(renderer,x,y);
}

W linijce 23 zaczyna się switch. Odpowiada on za to, abyśmy rysowali punkty w odpowiednim kolorze. W zależności od stanu drzewa kolor zmieniamy od zielonego (zdrowe drzewo) przez czerwony (pożar) do ciemnobrunatnego (spalone drzewo). Kolor rysowania punktów ustawiamy tą samą funkcją, której używaliśmy w metodzie clear, czyli SDL_SetRendererDrawColor.

Samo ustawienie koloru pędzla nic nie da. Musimy jeszcze narysować punkt w buforze. Służy do tego funkcja SDL_RenderDrawPoint. Działanie realizowane jest w linijce 36.

gameLoop

Metoda ta jest naszym SDL-owym „mainem”. To właśnie wewnątrz niej w przyszłości znajdzie się zarówno obsługa zdarzeń wejścia/wyjścia, narzędzi którymi możemy wpływać na stan symulacji, jak i wielu innych rzeczy. Na razie mamy tylko nieskończoną pętlę. Zmienna is_running typu bool pilnuje, aby program zakończył się wtedy kiedy użytkownik sobie tego zażyczy. Działanie to zaimplementujemy w kolejnej części serii. Jak wygląda generowanie każdej pojedynczej klatki?

36
37
38
39
40
41
42
43
44
void SDLMain::gameLoop() {
    bool is_running = true;
    while(is_running) {
        clear();
        map->drawAll(func);
        SDL_RenderPresent(renderer);
        SDL_Delay(1000/60);
    }
}

Najpierw czyścimy ekran z pozostałości po poprzedniej klatce (linijka 30). Następnie rysujemy wszystkie punkty, które znajdują się w ForestMap. Metoda drawAll przyjmuje wskaźnik na funkcję func. Do wskaźnika jak pamiętasz przypisaliśmy metodę drawPoint. To właśnie tej metody używa drawAll do rysowania poszczególnych punktów.

Kolejna linijka to funkcja SDL_RenderPresent() SDL2 korzysta z tzw. podwójnego buforowania. Oznacza to, że wszystko co rysujemy nie pojawia się natychmiastowo na ekranie. Zamiast tego informacje wędrują do specjalnego bufora. Na monitorze nie zobaczymy żadnych zmian, dopóki nie wywołamy metody SDL_RenderPresent, która kopiuje zawartość bufora na ekran.

Wzbudzająca zdumienie może być ostatnia linijka tej pętli. Po co nam SDL_Delay? Po co sztucznie opóźniać, spowalniać program? Gdy usuniesz tę linijkę, a następnie skompilujesz i uruchomisz program, zobaczysz coś zdumiewającego. Mimo iż będzie wyświetlany jedynie czarny ekran, program będzie brał 100% CPU! Dlaczego? Ponieważ domyślnie komputer będzie starał się wygenerować tyle klatek na sekundę, ile zdoła. SDL_Delay pozwala nam oszczędzić moc procesora wprowadzając sztuczne ograniczenie w postaci 60 klatek na sekundę. Będzie to wartość wystarczająca raczej dla każdego :).

setResolution

45
46
47
48
void SDLMain::setResolution(int width,int height) {
    SDL_RenderSetLogicalSize(renderer,width,height);
    SDL_RenderSetViewport(renderer,nullptr);
}

Została nam ostatnia metoda do napisania. SetResolution, ustawiająca rozdzielczość logiczną. Jest ona banalna. Składa jedynie z dwóch linijek. W pierwszej ustawiamy logiczny rozmiar okna, a w kolejnej zmuszamy SDL-a do wzięcia naszych zmian pod uwagę.

Metoda main! Uruchamiamy program 🙂

Pora napisać metodę main 🙂 Wykasuj HelloWorld czy coś innego, co tworzy twoje IDE domyślnie przy tworzeniu projektu. Main będzie prosty – składa się jedynie z 12 linijek.

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include "SDLMain.h"
using namespace std;

int main()
{
    SDLMain sdl;
    sdl.init();
    sdl.clear();
    sdl.gameLoop();
    return 0;
}

W linijce 7 tworzymy obiekt naszej klasy SDLMain. W kolejnej wywołujemy metodę init() (można to uprościć, gdyż deFacto wszystko co robimy w metodzie init() moglibyśmy umieścić w konstruktorze klasy SDLMain). Następnie czyścimy ekran i uruchamiamy pętlę naszej aplikacji. To tyle 🙂

Symulator pożaru lasu wynik działania cz. 2

Powyższy screen przedstawia efekt naszych dwu-odcinkowych prac. Nic niezwykłego, prawda? Ale podstawowa mechanika już istnieje. Możesz testowo dodać jakieś drzewa/pożary. Powinieneś zrobić to w metodzie init() klasy SDLMain() za pomocą metody add ForestMapy. Pobaw się, poeksperymentuj. Już niedługo rozbudujemy ten program.

Co dalej?

Zrobiliśmy ogromny postęp w stosunku do poprzedniej części. Nasz program już się uruchamia! Gdy dodamy punkty na sztywno w programie to nawet coś rysuje! Jest moc! W następnej części postaramy się zaimplementować kilka podstawowych narzędzi, takich jak ołówek czy spray, za pomocą których będziemy mogli przygotować sobie obszar symulacji.

Jeśli masz jakieś uwagi, zapraszam do wyrażenia swojej opinii 🙂 Kompletny kod omawianego wyżej projektu znajduje się w tym miejscu.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.