SFML-owe zabawy #13 - Warstwy


2019-01-31, 01:06

Powoli zbliżamy się do drugiej części projektu: mechanizmu grania. Już na prawdę niewiele brakuje do końca tworzenia edytora! :) Przyjrzyjmy się dziś mechanizmowi warstw, który właśnie zaimplementowałem, bo jest on na prawdę świetny :).


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


Dodatkowo informuję, że wszystkie prawa do grafik używanych w projekcie należą do firmy Nintendo. Projekt ma na celu jedynie propagowanie wiedzy o języku C++.


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

Udało mi się sporo zrefaktoryzować. Dokładniej, poprzenosiłem trochę klas w projekcie, co daje mi spore wrażenie porządku. Dodatkowo miałem ostatnio nieco refleksji na temat smart pointerów, o czym nieco dziś poopowiadam. Jako gwóźdź programu zaprezentuję Wam dziś również mechanizm warstw: nową funkcjonalność, która popchnęła projekt do przodu.

Refactoring

Znowu dużo działo się w środku. To lubię :) Tym razem nic nie wydzieliłem, ale za to nauczyłem się czegoś. Dodatkowo posprzątałem dużo w środku, ale wyjątkowo cały proces opiszę podczas omawiania nowej funkcjonalności, bo to właśnie dzięki niej jest teraz tak czysto! :)

Powrót do shared_ptr

Pod ostatnim wpisem odezwał się do mnie Maciej, którego zaintrygował mój sposób używania unique_ptr. Mowa o przekazywaniu tego typu wskaźników przez referencje.

Komentarz od Macieja

Z komentarzy dyskusja przeniosła się bezpośrednio na facebooka. Maciek wytłumaczył mi, dlaczego przekazywanie unique_ptr przez referencję to antywzorzec.

Pracując z inteligentnymi wskaźnikami musimy każdorazowo odpowiedzieć sobie na pytanie: kto będzie właścicielem pamięci przechowującej wartość? Przy unique_ptr pamięć przechowująca wartość może mieć tylko jednego właściciela. Przekazując ten typ wskaźnika dalej przez referencję, wcale nie mnoży nam się liczba właścicieli (tak się dzieje, gdy pracujemy z shared_ptr). Kiedy właściciel zdecyduje się zniszczyć obszar pamięci przechowujący wartość, to miejsce posiadające referencję do tego wskaźnika nie zostanie o tym poinformowane. Przy użyciu referencji na nieistniejący już wskaźnik dostaniemy błąd.

Dlaczego ja nie miałem z tym problemów? Jeżeli mówimy w kontekście unique_ptr, to w moim kodzie nie dochodziło nigdy do użycia referencji do wskaźnika po zniszczeniu samego wskaźnika. Jeżeli jednak cofniemy się do poprzedniego wpisu, to poczytamy sobie o referencjach do shared_ptr, które przysporzyły mi wtedy trochę problemów.

Postanowiłem posprzątać tutaj nieco i przywrócić (prawie) wszystkie unique_ptr na shared_ptr. Prawie, ponieważ w kilku miejscach stosowanie unique_ptr nie stanowiło problemu. Mowa o tych fragmentach kodu, które nie przekazywały wskaźnika na zewnątrz klasy.

Dodatkowo, w przyszłości przyglądnę się wszystkim shared_ptr i wykluczę wszystkie zależności od innych shared_ptr-ów, aby nie powstał problem zakleszczenia.

Tolerancja dla systemu kolizji

System kolizji sprawdzał się do tej pory bardzo dobrze. Do tego stopnia, że ciężko było wcisnąć figurę pomiędzy dwie pozostałe, jeżeli miejsca pomiędzy nimi zostało na styk. Dobrym rozwiązaniem okazało się dorzucenie małej tolerancji, która pozwoli nam “wcisnąć” obiekty jeden w drugi o zaledwie kilka pikseli. W kodzie wygląda to następująco:

Collision::Direction Collision::checkCollision(sf::Rect<float> second) const {
    auto centerFirst = calcCenter(first);
    auto centerSecond = calcCenter(second);

    auto diff = centerFirst-centerSecond;

    bool isOutsideX = abs((long int)diff.x) >= first.width/2 + second.width/2 - precision;
    bool isOutsideY = abs((long int)diff.y) >= first.height/2 + second.height/2 - precision;
    if (isOutsideX || isOutsideY) {
        return Direction::None;
    }

    bool isHorizontalPlacement = abs((long int)diff.x) > abs((long int)diff.y);
    if (isHorizontalPlacement) {
        if (centerFirst.x > centerSecond.x) {
            return Direction::Left;
        }
        return Direction::Right;
    }

    if (centerFirst.y > centerSecond.y) {
        return Direction::Top;
    }
    return Direction::Bottom;
}

Dorzuciłem kilka testów, które utwierdziły mnie mnie w tym, że to działa:

TEST(CollisionTest, test_not_collide_vertical_with_precision) {
    float precision = 30.0f;
    sf::Rect<float> second(point.x, point.y-height+precision-1, width, height);

    Collision collisionDetect(first, precision);
    ASSERT_EQ(Collision::Direction::None, collisionDetect.checkCollision(second));
}

TEST(CollisionTest, test_collide_vertical_with_precision) {
    float precision = 30.0f;
    sf::Rect<float> second(point.x, point.y-height+precision+1, width, height);

    Collision collisionDetect(first, precision);
    ASSERT_EQ(Collision::Direction::Top, collisionDetect.checkCollision(second));
}

TEST(CollisionTest, test_not_collide_horizontal_with_precision) {
    float precision = 30.0f;
    sf::Rect<float> second(point.x-width+precision-1, point.y, width, height);

    Collision collisionDetect(first, precision);
    ASSERT_EQ(Collision::Direction::None, collisionDetect.checkCollision(second));
}

TEST(CollisionTest, test_collide_horizontal_with_precision) {
    float precision = 30.0f;
    sf::Rect<float> second(point.x-width+precision+1, point.y, width, height);

    Collision collisionDetect(first, precision);
    ASSERT_EQ(Collision::Direction::Left, collisionDetect.checkCollision(second));
}

Kiedy testy zapaliły się na zielono, nie pozostało mi nic innego niż sprawdzić to klikając w edytorze. Okazuje się, że poprawka działa świetnie :).

Mechanizm warstw

Idea tej funkcjonalności była prosta: tworzymy prosty mechanizm pozwalający na skalowanie siebie w przyszłości. Oddzielamy niepasujące do siebie elementy tak, aby całość wyglądała spójnie. Wydaje mi się, że wyszło… idealnie. Przez przełączanie nawigacji zmieniamy kontekst, w którym aktualnie się znajdujemy. Dodatkowo blokujemy i wycofujemy wszystkie elementy spoza tego kontekstu. Dzięki temu drobnemu zabiegowi mam wrażenie, że użytkownikowi nie miesza się w głowie :) A Wy co sądzicie o tym mechanizmie?

Poświąteczne porządki

Oprócz samej potrzeby nowego mechanizmu narodziła się również potrzeba refactoru. Od pewnego czasu w projekcie zaczął narastać spory nieporządek. Miałem wrażenie, że brakuje mi podziału, który odseparowałby figury od tilesów. Postanowiłem nieco zamieszać w strukturze plików. Muszę przyznać, że ta zmiana bardzo się opłaciła :) Spójrzcie sami! :) Po lewej jest kod sprzed zmian, niepoukładany. Po prawej mamy nową, uporządkowaną architekturę:

Architektura warstw

Architektura “Od ogółu do szczegółu”

Trzecim dobrem, które przyniosła funkcjonalność warstw jest architektura, która powoduje, że w bardzo łatwy sposób możemy dowiedzieć się, z czego składa się proces rysowania, czy obsługi zdarzeń. Weźmy na przykład obsługę zdarzeń. W pętli głównej programu potrzebujemy obsłużyć zdarzenia dla całego edytora:

int Main::run() {
    editor->start();

    while (window->isOpen()) {
        systemEventHandler->handleEvents(window);

        if (editor->isStarted()) {
            editor->runAnimations();

            editor->handleEvents(systemEventHandler->keyboard, systemEventHandler->cursor);
            editor->draw(window);
        }

        cursor.draw(window);
        window->display();
    }
    return 0;
}

Co to znaczy? To znaczy, że musimy obsłużyć zdarzenia dla wszystkich jego składowych: nawigacji oraz sceny:

void Editor::handleEvents(Keyboard& keyboard, Cursor& cursor) {
    navigation->handleEvents(keyboard, cursor);
    scene->handleEvents(keyboard, cursor);
    currentState->dismissObjectDrop = false;
}

Czym jest obsługa zdarzeń dla nawigacji? Jest to obsługa zdarzeń dla wszystkich jej składowych:

void Navigation::handleEvents(Keyboard &keyboard, Cursor &cursor) {
    navigationPanels.at(currentNavigationPanel)->handleEvents(keyboard, cursor);

    if (cursor.isOver(switcher->getPosition(), switcher->getSize())) {
        if (!switcher->isMouseOver()) {
            switcher->mouseEnter(animationPerformer);
        } else if (cursor.isMousePressed() && !eventRegistry->isClickRegistered(switcher)) {
            eventRegistry->registerClick(switcher);
            cursor.mousePress(false);

            switchNavigationPanel();
        } else if (!cursor.isClick() && eventRegistry->isClickRegistered(switcher)) {
            eventRegistry->unregisterClick(switcher);
        } else {
            switcher->mouseOver(animationPerformer);
        }
    } else if (switcher->isMouseOver()) {
        switcher->mouseLeave(animationPerformer);
    }
}

W chwili aktualnej składową nawigacji jest… warstwa z przyciskami oraz przełącznik zmieniający aktualną nawigację. W taki sposób możemy lecieć w głąb do woli. To rozwiązanie sprawdza mi się na tą chwilę idealnie.

Aktualny efekt

Mam wrażenie, że wszystko do siebie idealnie pasuje. Jest proste i funkcjonalne. Sprawdźcie sami!

Warstwy

Czego nie udało się zrobić?

Planowałem wrzucić dwie funkcjonalności, a weszła jedna. Dodatkowo znowu cierpią testy. O ile przetestowałem tolerancję dla kolizji (całe cztery testy!), to poza infrastrukturą nie mam przetestowanego nic. Mimo to do całości podchodzę bardzo optymistycznie, bo projekt idzie do przodu, i to w dobrą stronę - czego chcieć więcej? :)

Plany na następną iterację

Jedziemy dalej z tematami z poprzedniego wpisu. Tak myślę sobie, aby temat warstw pociągnąć dalej i następną funkcjonalność dorzucić jako trzecią warstwę. Co sądzicie? Dodatkowo może wyjdę z testami poza katalog classes/Infrastructure. Jak myślicie, uda się? Mam nadzieję, że tak. A teraz w punktach:

  1. Określenie i modyfikacja punktu startowego i końcowego.
  2. Testy?

Do następnego razu! :)



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.