SFML-owe zabawy #6 - Refactoring


2018-09-05, 00:00

Czasami trzeba się cofnąć, aby móc pójść do przodu. Albo chociaż zatrzymać się, i spojrzeć na wszystko z innej perspektywy. Tymi stwierdzeniami rozpocząłem kolejną iterację, wchodząc w proces refactoringu. Zapraszam!


Uwaga. Wszystko, o czym piszę poniżej znajduje się na branchu iteration-5 w repozytorium projektu, o tutaj: https://github.com/CppPolska/MarioEdit/tree/iteration-5. Jeżeli masz uwagi odnośnie kodu, bądź chcesz dołączyć do projektu - daj znać w komentarzu :)


Co udało się zrobić podczas tej iteracji?

W tej iteracji przez długi czas zbyt wiele się nie działo. Założyłem sobie, że nie dorzucę nic nowego, dopóki nie posprzątam. Tak też zrobiłem. Dopiero pod koniec dorzuciłem kilka nowych elementów. Teraz to dopiero wygląda! :)

Dlaczego nadszedł czas na refactoring?

Tworząc nowe funkcjonalności, nie zawsze wszystko idzie zgodnie z planem. Na początku wychodzimy z błędnych założeń, a później staramy się łatać nasze pomysły tak, by wszystko trzymało się kupy. Ja w odpowiednim momencie wyłapałem, że coś jest grubo nie tak. Idąc dalej tą ścieżką, zacząłbym kopać pod sobą dołki. Oczywiście, dzisiejsze zmiany to tylko wierzchołek góry lodowej - powoli, a będzie coraz lepiej! :)

Wydzielanie odpowiedzialności

Wszystko zaczęło się od drobnego nieporozumienia: chciałem narysować chmurkę, ale… ta okazuje się nieco innym elementem niż puzelek z pytajnikiem. Póki co, ona nie potrzebuje mechanizmów obsługi zdarzeń kursora. Poza tym nawet, gdyby miała obsługiwać te zdarzenia, to nie zadziałałoby to tak jakbym tego chciał. Chmurka musiałaby reagować na zdarzenia jako jeden spójny element, a w ówczesnej formie każdy z puzelków budujących tą chmurkę reagowałby z osobna na powstałe wydarzenia. Mamy zatem konflikt do rozwiązania.

Rozbrojenie klasy Game

Pierwszy potworek, który postanowiłem rozbroić to klasa Game. Tutaj mieliśmy aż trzy różne odpowiedzialności! Udało mi się wydzielić takie klasy jak:

  • Game - czyli zapętlenie głównego wątku oraz re-inicjalizacja obiektu okna.
  • EventHandler - klasa obsługująca wydarzenia występujące w trakcie trwania programu.
  • Scene - wszystkie informacje o ekranie głównym. Tutaj mamy obiekt grid-u, tiles-y, figury. Tutaj również odbywa się w chwili obecnej rysowanie wszystkich elementów znajdujących się w oknie.

Ten podział odpowiedzialności spowodował, że na nowo spojrzałem na projekt. Tym razem widziałem go jako jedną całość. Byłem w stanie wyobrazić sobie nowe ścieżki, którymi podążę w przyszłości. Poniżej przedstawiam publiczne interfejsy każdej z wydzielonych klas:

class Game {

public:

    Game();
    int run();
}
class Scene {

public:

    explicit Scene(std::shared_ptr<sf::RenderWindow> window);

    std::shared_ptr<Scale> getScale();

    void rescale();
    void runTasks();
    void draw();
}
class EventHandler {

public:

    enum Event {
        QuitGame,
        ToggleFullScreen,
        ResizeWindow,
    };

    explicit EventHandler(Cursor& cursor, std::shared_ptr<Scale> scale);

    sf::Event getLastEvent();
    void addEventHandler(Event event, std::function<void()> callback);
    void handleSystemEvents(std::shared_ptr<sf::RenderWindow> window);
    void handleDynamicTilesEvents();
}

O wiele czytelniej, niż to było wcześniej.

Wydzielenie typów puzelków

Do tej pory w projekcie była jedna klasa Tile, która mogła wszystko. Reagowała na wydarzenia kursora myszy, przyklejała się do siatki, mrugała. Trochę dużo, jak na jedną klasę. Łamiemy SRP w sposób jawny. Nie zostało mi nic innego, jak… wydzielić mniejsze klasy i użyć mechanizmu dziedziczenia. Powstało w ten sposób kilka klas, dziedziczących po sobie. Jest pięknie.

Pamiętajmy, że moment refactoru, to dobry moment na to, aby zaplanować kilka kroków do przodu. Utworzyłem zatem klasę abstrakcyjną Figure, z której wychodzą takie klasy jak: Cloud, Bush czy Hill. Są to kontenery obiektów klasy StaticTile, grupujące je w większe figury. W niedalekiej przyszłości to te kontenery będą obsługiwały wydarzenia kursora.

Cały proces wydzielania typów puzelków przedstawiłem na obrazku poniżej:

Pomniejsze zmiany

Oprócz powyższego, zrobiłem szereg mniejszych modyfikacji w obrębie całego kodu.

1. Załączanie nagłówków zgodnie z użyciem klas

Można powiedzieć, że ten punkt był natchnieniem do napisania tego posta. Muszę tego pilnować, bo czasami przenosząc kod zostawiam za sobą niepotrzebne #include, albo nie przenoszę ich do plików źródłowych, kiedy przestają być użyteczne w nagłówkach.

2. Użycie mutex-ów w kodzie wielowątkowym

Użyłem wątków. Zrobiłem to nie tak, jak potrzeba. Pierwszym, co było złe - jest niezabezpieczenie kodu uruchamianego w nowym wątku przed wystąpieniem data racing-u. Po zwróceniu uwagi kilku osób zagłębiłem się nieco w temat, i użyłem mutex-ów tam, gdzie potrzebowałem pewności, że jeden wątek nie podmieni drugiemu danych w trakcie jego wykonywania.

Drugim, co jest złe, to uruchamianie jednego wątku specjalnie po to, by wyanimować sobie puzelek. Nie pomyślałem o tym, co się stanie jak będę chciał uruchomić kilkanaście animacji jednocześnie. Uruchomimy kilkanaście nowych wątków? Chyba nie. Mam na to plan, ale myślę, że to temat na całkiem osobny wpis. Póki co, zostanie tak jak jest, ale to na pewno zostanie zmienione.

3. Użycie pętli for-range

Sporym przyzwyczajeniem dla mnie było pisanie pętli for trój-argumentowej:

for (int i=0; i<tiles.size(); i++) {
    auto tile = tiles.at(i);
    // ...
}

A przecież można ładniej i łatwiej:

for (auto tile : tiles) {
    // ...
}

Znalazłem wszystkie wystąpienia iterowania po wektorach, i zamieniłem je na for-range.

4. Zmiana sposobu rysowania siatki

Od teraz zamiast rysować siatkę metodą skrzyżowanych linii, rysujemy ją poprzez cykliczne rysowanie linii w kształcie odwróconej litery L. Dzięki temu mogłem ustawić na siatce półprzeźroczystość, która wygląda zdecydowanie ładniej. Przy poprzednim sposobie rysowania ustawienie półprzeźroczystości powodowało, że miejsca łączeń linii pionowych z poziomymi były nieco innego koloru, niż same linie.

Teraz siatkę rysujemy tak:

void Grid::draw(std::shared_ptr<sf::RenderWindow> window) {
    for (int i=0; i<rows+1; i++) {
        for (int j=0; j<cols+1; j++) {
            drawHorizontalLine(i, j, window);
            drawVerticalLine(i, j, window);
        }
    }

    if (highlightFlag) {
        drawHighlight(window);
    }
}

void Grid::drawHorizontalLine(sf::Uint32 row, sf::Uint32 col, std::shared_ptr<sf::RenderWindow> window) {
    sf::RectangleShape line;
    line.setPosition(sf::Vector2f(col*lineDistance, row*lineDistance-lineThickness));
    line.setFillColor(lineColor);
    line.setSize(sf::Vector2f(lineDistance-lineThickness, lineThickness));

    window->draw(line);
}

void Grid::drawVerticalLine(sf::Uint32 row, sf::Uint32 col, std::shared_ptr<sf::RenderWindow> window) {
    sf::RectangleShape line;
    line.setPosition(sf::Vector2f(col*lineDistance-lineThickness, row*lineDistance));
    line.setFillColor(lineColor);
    line.setSize(sf::Vector2f(lineThickness, lineDistance));

    window->draw(line);
}

We wpisie poświęconym siatce zamieściłem poprzedni algorytm. Znajdź różnicę! ;)

5. Klasa Scale już nie jest statyczna

Statycznie jest łatwiej, ale nie zawsze ładniej. Gorzej o testy, bo klas statycznych nie można mockować. Jeden statyczny potworek mniej! :)

6. Implementacja algorytmu SmoothStep function

Na wykopie dostałem cynk, że częściej do easingu używa się funkcji SmoothStep. Zaimplementowałem ją i użyłem przy wyróżnianiu puzelka. Wygląda ładnie :) Poniżej implementacja:

float SmoothStepFunction::getValue(sf::Int32 time) {
    float timePart = (float)time/(float)duration;
    float valueDiff = targetValue-startValue;
    float smoothStep = smoothstep(0.0f, 1.0f, timePart);
    return valueDiff*smoothStep + startValue;
}

float SmoothStepFunction::smoothstep(float edge0, float edge1, float x) {
    x = clamp((x - edge0) / (edge1 - edge0), edge0, edge1);
    return x * x * (3 - 2 * x);
}

float SmoothStepFunction::clamp(float x, float lowerLimit, float upperLimit) {
    if (x < lowerLimit) {
        x = lowerLimit;
    }
    if (x > upperLimit) {
        x = upperLimit;
    }
    return x;
}

7. Dorzucenie interfejsów HoverableInterface oraz DraggableInterface

Interfejsy to prawdziwe bogactwo. Wydzieliłem dwa interfejsy HoverableInterface oraz DraggableInterface z myślą o klasach z rodziny Figure. W przyszłości na pewno będą implementować obydwa. Oto one:

class HoverableInterface {

public:

    virtual void mouseEnter()=0;
    virtual void mouseLeave()=0;

};
class DraggableInterface {

public:

    virtual void startDrag()=0;
    virtual void drag()=0;
    virtual void drop()=0;

};

Pojawiły się nowe puzelki!

Na sam koniec zostawiłem to, co cieszy oko najbardziej! :) Mamy nowe puzelki, i uwaga! Pojawiła się również chmurka, krzaczek i wzgórze :) Stwierdziłem, że ta iteracja nie może skończyć się na samym refactorze. Tym samym przygotowałem sobie planszę na kolejną iterację, ale o tym poniżej.

Czego nie udało się zrobić?

Niestety, nie zrobiłem obsługi nowego mechanizmu klikania, o którym pisałem w poprzednim wpisie. Ale czy to źle? Z jednej strony tak, bo poraz kolejny moje założenia zmieniły się w trakcie iteracji. Z drugiej jednak strony, nikt mnie nie goni. Mam czas, aby przemyśleć rozwiązania, które mam w głowie.

Efekt na dzień dzisiejszy

Jest świetnie! :) Robi się coraz ładniej i bardziej kolorowo. Jest kilka bugów do poprawy, ale nie mówmy póki co o tym. Patrzcie jakie to fajne:

Kilka puzelków

Plany na następną iterację

Powolutku zmierzamy ku działającemu prototypowi! :) Teraz mam w planach skupić się na pasku pomocniczym, z którego będzie można ściągać puzelki na planszę. Tym samym będę musiał popracować trochę nad mechanizmem przeciągania i upuszczania puzelków. Mam nadzieję, że szybko mi z tym pójdzie. Dodatkowo, potrzeba będzie abym przemyślał temat związany z animacjami, bo tak być nie będzie ;).

Ok, zatem po kolei:

  1. Tilebar.
  2. Zmiana konceptu klikania na puzelkach.
  3. Porządek z animacjami.

A może Ty chcesz dołączyć do projektu? :)


Do następnego razu! Piona! :)



Marcin Kukliński

Zawodowo backend developer, hobbystycznie pasjonat języka C++. Po godzinach poszerza swoją wiedzę na takie tematy jak teorii kompilacji oraz budowa formatów plików. Jego marzeniem jest stworzyć swój własny język programowania.

Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.