Symulator pożaru lasu – dodajemy narzędzia

Czas czytania: 7 minut

Semestr się już zakończył … Nie dałem rady opisywać na bieżąco rozwoju projektu symulatora pożaru lasu. Na szczęście wszystko zaliczyłem w pierwszym terminie i teraz mam trochę wolnego czasu. Dzięki temu moge nadrobić wymuszone przez wyższe czynniki zaległości. Zapraszam do lektury 🙂

Co będziemy robili? Dzisiaj dodamy do programu podstawowe narzędzia, które będą pozwalały na stworzenie „środowiska symulacji”. Po wprowadzeniu dzisiejszych modyfikacji będziemy mogli na różne sposoby rysować drzewa, pożary i spalone drzewa. Jeśli chcesz przypomnieć sobie, co już osiągnęliśmy, zajrzyj do poprzedniego wpisu tej serii. No to co, zaczynamy?

Poprawki w dotychczasowym kodzie

Musimy wprowadzić kilka poprawek w dotychczasowym kodzie. Jakich? Jeśli zajrzysz do pierwszej i drugiej części tej serii zauważysz, że zapomnieliśmy o kilku rzeczach. Przede wszystkim nie wzięliśmy pod uwagę możliwości usuwania punktów. Będzie nam niezbędna w momencie gdy będziemy chcieli zrobić narzędzie służące do usuwania narysowanych drzew/pożarów. Moglibyśmy pominąć tworzenie takiego narzędzia. Lecz czy nie powinniśmy brać pod uwagę tego, że użytkownikowi może się omsknąć ręka i narysuje drzewo nie w tym miejscu co chciał? No właśnie…

Pierwsza zmiana dotknie enum PType. Dodamy w nim kolejną pozycję: NONE. Enum po zmianach będzie wyglądało tak:

12
13
14
15
16
17
enum PType {
    EXIST,
    FIRE,
    BURN,
    NONE
};

Szczegóły użycia ostatniej opcji poznasz w dalszej części artykułu.

Dodamy do ForestMapy możliwość usuwania punktów. Będzie to kolejna metoda klasy ForestMap. Wygląda ona następująco:

89
90
91
    void del(int x,int y) {
        (*fullMap).erase(Coordinates(x,y));
    }

Naszymi argumentami są współrzędne punktu który chcemy usunąć. Jak realizujemy tę operację? Po prostu usuwamy z mapy punkt o danych koordynatach. Nic skomplikowanego, prawda?

Garść teorii – hierarchia klas

Naszym celem na dzień dzisiejszy jest zaimplementowanie systemu narzędzi. Wg założeń, powinniśmy mieć do dyspozycji następujące narzędzia:

  • pojedynczy punkt (rysuje drzewo/pożar/spalone drzewo po kliknięciu lewego przycisku myszy (LPM)
  • ołówek (zaczyna rysować po naciśnięciu LPM. Kończy rysować po puszczeniu LPM)
  • spray (rysuje kilkanaście rozrzuconych punktów wewnątrz okręgu o określonym rozmiarze, analogicznie jak spray w Paincie)

Jak możemy zaimplementować powyższe narzędzia? Zauważyłeś w nich coś ciekawego? Widzisz jakieś wspólne elementy? Otóż, każde narzędzie rysuje jeden z czterech rodzajów punktu. Typy tych punktów zadeklarowaliśmy już wcześniej w enum PType. Zauważ, że każde z narzędzi rysuje punkt w jednej z trzech chwil: naciśnięcie LPM, puszczenie LPM lub ruszanie kursorem po wciśnięciu LPM. Przyuważenie tych wspólnych cech pozwala nam napisać klasę abstrakcyjną ATool, która deklaruje podstawowe metody służące do używania wybranego narzędzia. Konkretne narzędzia, takie jak ołówek czy spray, będą dziedziczyły po ATool, nadpisując potrzebne im do poprawnego działania metody.

Deklaracja i implementacja narzędzi

ATool – klasa bazowa dla wszystkich narzędzi

4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ATool {
public:
    virtual void onMouseDown(unsigned int x,unsigned int y) {}
    virtual void onMouseUp(unsigned int x,unsigned int y) {}
    virtual void onMouseMotion(unsigned int x,unsigned int y) {}
    void changeType(PType __newType);
    PType getType() {return type;}
    virtual ~ATool() {};
protected:
    ATool(PType __type,ForestMap* __map) :map(__map), type(__type) {}
    ForestMap* map = nullptr;
private:
    PType type;
};

Spójrz na powyższy kod. Stanowi on kwintesencję tego, co powiedzieliśmy w poprzednim rozdziale.

Zacznijmy od prywatnych atrybutów klasy. Zdefiniowaliśmy zmienną type, która przechowuje rodzaj punktów aktualnie rysowanych przez narzędzie. Po co nam ForestMap i to jeszcze w formie staroświeckiego wskaźnika? Potrzebujemy go ponieważ ołówek czy spray musi mieć możliwość realizacji swojego zadania, czyli dodania jednego lub większej ilości punktów. Narzędzie nie zarządza czasem życia ForestMapy, więc nie musimy jej przekazywać całego unique_pointera. Wystarczy, że przekażemy wskaźnik na obiekt.

Lecz powstaje kolejne pytanie. Dlaczego ForestMap jest elementem „chronionym” a nie „prywatnym” klasy? Zrobiliśmy tak dlatego, gdyż klasy potomne ATool muszą mieć możliwość dodawania lub usuwania punktów z ForestMapy. Gdybyśmy ForestMap uczynili prywatnym atrybutem klasy, klasy potomne nie miałyby do niego dostępu.

Hmmm. Konstruktor też jest chroniony. Jakie ma to uzasadnienie? ATool nie reprezentuje żadnego „konkretnego” narzędzia. Nie chcemy aby była możliwość utworzenia obiektu tej klasy. Klasa ATool ma stanowić jedynie „szablon” dla swoich potomków. Umieszczenie konstruktora w sekcji chronionej jest idealnym rozwiązaniem. Obiektu takiego tworu nie będzie można utworzyć. Jednocześnie klasy pochodne mogą korzystać z konstruktora klasy ATool.

Co możemy powiedzieć o publicznych metodach? Przede wszystkim spoglądamy na wiersze 6-8. Te metody będą odwalały najwięcej brudnej roboty. Będą wywoływane będą odpowiednio gdy zostanie naciśnięty/puszczony lewy przycisk myszy. Ostatnia metoda będzie wywoływana wtedy, gdy kursor myszy wykona ruch. Powstaje pytanie, dlaczego są one wirtualne? Chcemy skorzystać z polimorfizmu. Operator virtual zapewnia nas, że zostanie wybrana odpowiednia wersja metody w zależności od rodzaju utworzonego obiektu.

Metoda changeType pozwala na zmianę rodzaju punktu rysowanego przez narzędzie. To tutaj będziemy wybierali, czy chcemy rysować np.: drzewa czy pożar. Analogicznie getType pozwala wybrać typ aktualnie rysowanego punktu.

Wiemy już, jak ogólnie ma wyglądać narzędzie. Pora wziąć się za implementację konkretnych rozwiązań 🙂

Pojedynczy punkt – najprostsze możliwe narzędzie

Przypomnijmy, za co ma odpowiadać narzędzie pojedynczy punkt? Otóż powinno rysować drzewo/pożar/spalone drzewo/czyścić w miejscu, które klikniemy LPM. Wnioskujemy z tego, że musimy nadpisać metodę onMouseDown. No to bierzmy się do roboty!

Definicja klasy w pliku nagłówkowym będzie wyglądała następująco:

19
20
21
22
class ToolSinglePoint : public ATool {
    ToolSinglePoint(PType __type,ForestMap* __map) : ATool(__type,__map) {}
    void onMouseDown(unsigned int x,unsigned int y) override;
};

Nic niezwykłego. ToolSinglePoint dziedziczy publicznie po ATool. Dlaczego publicznie? Ponieważ chcemy, aby publiczne metody pozostały publiczne, chronione – chronione a prywatne – prywatne. Tylko dziedziczenie publiczne pozwala nam na takie coś.

Nadpisaliśmy tylko metodę onMouseDown. W niej będziemy dodawali punkt do ForestMapy. Jak będzie wyglądała ta operacja? Zobaczmy:

6
7
8
9
10
11
12
void ToolSinglePoint::onMouseDown(unsigned int x,unsigned int y) {
    if(getType()!=NONE)
        map->add(x,y, getType());
    else {
        map->del(x,y);
    }
}

W linijce 7 zauważamy zastosowanie nowej pozycji enum PType. Sprawdzamy, czy nie jest on równy NONE. W takim wypadku po prostu dodajemy punkt o okreśłonym typie znajdujący się w określonym miejscu do ForestMapy. Jeśli typ rysowanego punktu to NONE, musimy usunąć wszystko co znajduje się w tym miejscu.

Ołówek – rysowanie ciągłe

Podnieśmy poprzeczkę o jedno oczko wyżej. Zaimplementujemy teraz ołówek. Jak on działa? Ma rysować punkty danego typu od momentu wciśnięcia LPM do momentu puszczenia.

Zastanówmy się chwilkę, czym będzie różnił się ołówek od ToolSinglePoint? W sumie różnica będzie bardzo niewielka. Obydwa narzędzia robią to samo przy naciśnięciu LPM. Wynika stąd, że metoda onMouseDown będzie identyczna. ToolSinglePoint rysuje punkty także podczas ruchu myszki. Wniosek jest taki, że powinniśmy nadpisać metodę onMouseMotion. Jak będzie wyglądało dodawanie punktów podczas ruchu myszki? Dokładnie tak samo jak przy kliknięciu.

Czy w takim wypadku ołówek rzeczywiście musi dziedziczyć po ATool? Otóż, niekoniecznie. Bliższym przodkiem dla ołówka jest ToolSinglePoint. I właśnie tak stworzymy to narzędzie.

25
26
27
28
29
class ToolPencil : public ToolSinglePoint {
public:
    ToolPencil(PType __type,ForestMap* __map) : ToolSinglePoint(__type,__map) {}
    void onMouseMotion(unsigned int x,unsigned int y) override {this->onMouseDown(x,y);}
};

Spray – losowe rozmieszczanie punktów

Spray będzie bardziej skomplikowany od ołówka czy pojedynczego punktu. Wykonajmy najpierw deklarację klasy.

30
31
32
33
34
35
36
37
38
class ToolSpray : public ATool {
public:
    ToolSpray(PType __type,ForestMap* __map,unsigned int size) : ATool(__type,__map)
    void onMouseDown(unsigned int x,unsigned int y) override;
    void onMouseMotion(unsigned int x,unsigned int y) override;
private:
     const int size = 5;
    const double numberOfPointsMultiplier = 0.5;
};

Nic niezwykłego. Tym razem nadpisujemy metody onMouseDown i onMouseMotion. W zmiennych prywatnych stworzyliśmy stałą size. Po co? Ponieważ spray musi rozprowadzać punkty w pewnej odległości od miejsca w którym kliknęliśmy. Wprowadzenie takiej stałej pozwoli nam łatwo dodać możliwość zmiany rozmiaru spraya w przyszłości.

Zmienna prywatna numberOfPointsMultiplier pozwala na zmianę ilości punktów generowaną przy pojedynczym wywołaniu jednej z metod on*.

Zobaczmy, jak możemy zdefiniować metody onMouseDown i onMouseMotion tak, aby spray działał prawidłowo.

15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void ToolSpray::onMouseDown(unsigned int x,unsigned int y) {
    if(getType()!=NONE) {
        unsigned int numberOfPoints = size*0.5;
        std::default_random_engine generator;
        generator.seed(std::chrono::system_clock::now().time_since_epoch().count());
        std::uniform_real_distribution<double> distribution(0,1);
        for(unsigned int i = 0;i<numberOfPoints;i++) {
            double r = size*sqrt(distribution(generator));
            double angle = distribution(generator)*2*M_PI;
            int pointx = x+r*cos(angle);
            int pointy = y+r*sin(angle);
            map->add(pointx,pointy,getType());
        }
    }
void ToolSpray::onMouseMotion(unsigned int x,unsigned int y) {
    this->onMouseDown(x,y);
}

W linijce 17 obliczamy ilość punktów, które wygenerujemy przy pojedynczym wywołaniu metody. Linijka 18,19 i 20 to konfiguracja generatora o rozkładzie równomiernym. Od linii nr 21 zaczynamy właściwe generowanie punktów.

Zakładamy, że punkt w którym użytkownik kliknął to środek koła. Najpierw obliczamy, w jakiej odległości od środka wygenerujemy nowy punkt. Kolejny krok to wyliczenie w której części koła zostanie utworzona kropka. Gdy mamy już te dwie zmienne, możemy obliczyć właściwe współrzędne x i y generowanego punkciku. Na samym końcu dodajemy punkt do mapy.

Skąd wzięły się takie a nie inne wzory? Nie jest to blog matematyczny. Artykuł też nie jest poświęcony zawiłościom królowej nauk. Jeśli jesteś zainteresowany szczegółami, mogę odesłać do ciekawej dyskusji na ten temat.

Jak skorzystać z narzędzi? Modyfikacja SDLMain

Utworzyliśmy już trzy podstawowe narzędzia, których będziemy używać. W przyszłości nieco je zmodyfikujemy tak, aby była możliwość zmiany rozmiaru spraya czy ołówka. Teraz spróbujmy połączyć nowo utworzone zabawki z głównym programem.

Na pierwszy ogień pójdzie plik nagłówkowy SDLMain.h. Co musimy zrobić, abyśmy mogli użyć narzędzia? SDLMain musi przechowywać wskaźnik do ATool. Dlaczego do ATool a nie konkretnego typu narzędzia? Ponieważ chcemy zapewnić sobie możliwość zmiany narzędzia podczas działania programu. Dzięki klasie, można powiedzieć, abstrakcyjnej ATool możemy w pełni skorzystać z tzw. polimorfizmu.

8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
    unique_ptr<ATool> tool;
    SDL_Window *win = nullptr;
    SDL_Renderer* renderer = nullptr;
    function<void(int,int,PType)> func;
};

Abyśmy mogli przetestować dane narzędzie, musimy zmodyfikować metodę init.

10
11
12
13
14
15
16
17
18
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>();
    map->add(50,50,EXIST);
    setResolution(100,100);
    tool = make_unique<ToolSpray>(EXIST,map.get());
}

Nowe linijki to 16 i 17. W linijce 16 ustawiamy wirtualną rozdzielczość za pomocą utworzonej w poprzedniej części metody. Robimy to aby punkty były większe. Gdybyśmy nie użyli tej metody to pojedynczy punkt na ekranie o wysokiej rozdzielczości byłby praktycznie niewidoczny.

Linijka 17 to utworzenie narzędzia (w tym wypadku Spray), które będzie rysowało drzewa. Jako drugi argument przekazujemy wskaźnik do ForestMapy, który uzyskujemy z unique_ptr’a za pomocą metody get. Jeśli chcemy przetestować inne narzędzie, tworzymy obiekt typu ToolSinglePoint albo ToolPencil.

Co jeszcze się zmieni? Każde z narzędzi posiada metody onMouseDown/Up/Motion. Musimy dodać obsługę zdarzeń i wywoływać te metody w odpowiedzi na nie. Spójrzmy więc, jak będzie wyglądała nowa wersja metody gameLoop.

40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
void SDLMain::gameLoop() {
    bool is_running = true;
    while(is_running) {
        SDL_Event ev;
        clear();
        while(SDL_PollEvent(&ev)!=0) {
            int x,y;
            SDL_GetMouseState(&x,&y);
            if(ev.type==SDL_MOUSEBUTTONDOWN && ev.button.button==SDL_BUTTON_LEFT) {
                tool->onMouseDown(x/7,y/7);
            }
            else if(ev.type==SDL_MOUSEBUTTONUP && ev.button.button==SDL_BUTTON_LEFT) {
                tool->onMouseUp(x/7,y/7);
            }
            if(ev.type==SDL_MOUSEMOTION && ev.button.button==SDL_BUTTON_LEFT) {
                tool->onMouseMotion(x/7,y/7);
            }
        }
        map->drawAll(func);
        SDL_RenderPresent(renderer);
        SDL_Delay(1000/60);
    }
}

W linijce 45 tworzymy pętlę, która będzie obsługiwała zdarzenia. Warunek zakłada, że obsługa zdarzeń będzie wykonywana dopóki, dopóty są jakieś zdarzenia do obsłużenia.

Linijka 46 i 47 pozwalają nam na pobranie i przechowanie współrzędnych na jakie obecnie wskazuje myszka użytkownika. Wykorzystujemy to nieco dalej.

Wiersze 48-56 to obsługa zdarzeń kliknięcia/puszczenia/ruchu myszy. W ramach reakcji na zdarzenie wywołujemy odpowiednią metodę z wybranego narzędzia. Dzięki odpowiednio zastosowanemu polimorfizmowi jesteśmy pewni, że zostanie wybrana odpowiednia wersja metody i wybrane przez użytkownika narzędzie będzie funkcjonowało tak jak powinno.

Na wyjaśnienie zasługuje dzielenie przez siódemkę każdej ze współrzędnych. Dlaczego to robimy? Jest to tymczasowe rozwiązanie. Okno, które utworzyliśmy, ma wymiary 700 na 700 pikseli. Wirtualna rozdzielczość naszego okna to 100 na 100. Musimy przeprowadzić konwersję współrzędnych „globalnych” na współrzędne „lokalne”. Nasze wirtualne okno ma 7 razy mniejszą rozdzielczość od właściwego okna. Dlatego dzielimy każdą współrzędną z procedury obsługi okna przez 7.

Tak prezentuje się teraz nasza aplikacja

Co dalej?

Idziemy powoli, ale do przodu. Nasza aplikacja pozwala już na malowanie różnokolorowych punktów. Można powiedzieć, że stworzyliśmy ubogą wersję Painta. Aplikacja posiada już jakąś funkcjonalność ale musisz pamiętać, że jeszcze dosyć dużo wody w Wiśle musi upłynąć zanim skończymy pracę.

Obecna wersja programu posiada kilka wad. Przede wszystkim, zmiana narzędzia (a nawet jego parametrów) wymaga zmiany kodu źródłowego i ponownej kompilacji programu. Drugim problemem jest to, że nie da się zmienić rozmiaru pędzli czy spraya. Jeśli pobawisz się tą aplikacją zauważysz jeszcze jeden uszczerbek. Narzędzia nie generują obwódki. W przypadku spraya musimy więc domyśłić się w którym miejscu mogą pojawić się punkty.

Te i być może inne problemy rozwiążemy następnym razem.

Standardowo, aktualny kod źródłowy jest dostępny w repozytorium na Githubie. Zapraszam do komentowania i dzielenia się uwagami 🙂

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.