SFML-owe zabawy #9 - Przemieszczanie elementów tła


2018-10-31, 00:00

Czas na dziewiątą już część SFML-owych zabaw! :) Jesteśmy o kolejny krok milowy do przodu - dziś możemy przesuwać chmurki, krzaczki i pagórki! Nasz projekt z iteracji na iterację nabiera barw - zobaczcie sami!


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


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

Ta iteracja również należy do bardzo udanych, bo zrobiłem coś, z czego jestem mega zadowolony. Refactor - cyk i jest. Nowa funkcjonalność - pyk i jest. Plany na przyszłość - są, i to nawet na daleką przyszłość :)

Refactoring na start

Wydzielania odpowiedzialności ciąg dalszy

Tradycyjnie, początek iteracji to najlepszy czas na zmiany i poprawki. Tak więc znalazłem nowe miejsce, z którego można wydzielić kilka odpowiedzialności. Padło na klasę Editor, której odpowiedzialność to między innymi obsługa zdarzeń. Po krótce interfejs tej klasy wyglądał następująco:

class Editor {

public:

    void handleEvents(Keyboard&, Cursor&) override;

private:

    void handleButtonTilesEvents(Keyboard&, Cursor&);
    void handleSceneTilesEvents(Keyboard&, Cursor&);

    void doButtonMouseOver(Cursor&, std::shared_ptr<ButtonTile>);
    void doButtonMouseOut(Cursor&, std::shared_ptr<ButtonTile>);
    void doButtonMouseClick(Cursor&, std::shared_ptr<ButtonTile>);

    void createDynamicTileSnappedToCursor(Cursor&, std::shared_ptr<ButtonTile>);

    void performLongClickDrop(Cursor&);
    void performQuickClickDrop(Cursor&);

    void performHover(Cursor&, std::shared_ptr<DynamicTile>&);
    void performDragDrop(Cursor&, std::shared_ptr<DynamicTile>&);
    void performDrop(Cursor&, std::shared_ptr<DynamicTile>&);
    void cancelDragging(Cursor&);
}

Wyobraziłem sobie, ile kolejnych prywatnych metod musiałbym dorzucić właśnie teraz, kiedy zabierałem się za obsługę zdarzeń dla elementów tła. I odnajdź się później w tym kodzie ;) Nie tędy droga! Trzeba było to wydzielić. Postanowiłem, że jedyna metoda, która tutaj zostanie - to ta publiczna.

Na początku utworzyłem klasę EditorEventHandler, która będzie fasadą dla całego mechanizmu obsługi zdarzeń. Następnie instancję tej klasy zamieściłem wewnątrz obiektu klasy Editor.

Kolejnym krokiem było utworzenie katalogu src/classes/Editor/EventHandler, po czym stworzyłem w nim dwie klasy, według odpowiedzialności:

  • ButtonTileEventHandler
  • DynamicTIleEventHandler

o następujących interfejsach:

class ButtonTileEventHandler : public AbstractTileEventHandler {

public:

    ButtonTileEventHandler(
        std::shared_ptr<EventState>,
        std::shared_ptr<AnimationPerformer>,
        std::shared_ptr<Scene>,
        std::shared_ptr<TileFactory>
        std::shared_ptr<TileEventRegistry>
    );
    void handleEvents(Keyboard&, Cursor&) override;

private:

    void doMouseOver(Cursor&, std::shared_ptr<ButtonTile>);
    void doMouseOut(Cursor&, std::shared_ptr<ButtonTile>);
    void doMouseClick(Cursor&, std::shared_ptr<ButtonTile>);
};
class DynamicTileEventHandler: public AbstractTileEventHandler {

public:

    DynamicTileEventHandler(
        std::shared_ptr<EventState>,
        std::shared_ptr<AnimationPerformer>,
        std::shared_ptr<Scene>,
        std::shared_ptr<TileFactory>,
        std::shared_ptr<TileEventRegistry>
    );
    void handleEvents(Keyboard&, Cursor&) override;

private:

    void performLongClickDrop(Cursor&);
    void performQuickClickDrop(Cursor&);

    void performHover(Cursor&, std::shared_ptr<DynamicTile>&);
    void performDragDrop(Cursor&, std::shared_ptr<DynamicTile>&);
    void performDrop(Cursor&, std::shared_ptr<DynamicTile>&);
};

Klasy te reprezentują obsługę zdarzeń dla każdego typu elementu z osobna. Na tą chwilę są to obiekty klasy DynamicTile oraz ButtonTile, ale zaraz dojdą tutaj również zdarzenia obiektów klasy Figure. Instancje tych klas obsługujących zdarzenia znajdują się wewnątrz obiektu klasy EditorEventHandler.

Jak możemy zauważyć, gdzieś uciekły nam metody cancelDragging(...) oraz createDynamicTileSnappedToCursor(...). Niestety, ale są to metody wykorzystywane w obydwu odpowiedzialnościach. Jedynym dobrym wyjściem był wybór dziedziczenia po wspólnej klasie bazowej. Stąd pojawiła się klasa AbstractTileEventHandler:

class AbstractTileEventHandler {

public:

    AbstractTileEventHandler(
        std::shared_ptr<EventState>,
        std::shared_ptr<AnimationPerformer>,
        std::shared_ptr<Scene>,
        std::shared_ptr<TileFactory>,
        std::shared_ptr<TileEventRegistry>
    );

protected:

    std::shared_ptr<EventState> eventState;
    std::shared_ptr<AnimationPerformer> animationPerformer;
    std::shared_ptr<Scene> scene;
    std::shared_ptr<TileFactory> tileFactory;
    std::shared_ptr<TileEventRegistry> tileEventRegistry;

    void createDynamicTileSnappedToCursor(Cursor&, std::shared_ptr<ButtonTile>);
    void cancelDragging(Cursor&);
};

Przy okazji, zamieściłem tam również wszystkie współdzielone przez te klasy pola. Wyjaśnić jeszcze należy, czym jest klasa EventState. Jest to tak na prawdę kolejny zbiór współdzielonych wartości, które mógłbym zawrzeć wewnątrz klasy AbstractTileEventHandler. Zauważyłem jednak wspólny kontekst dla tych wartości, więc wydzieliłem sobie do tego celu osobną klasę.


Pojawiły się nowe interfejsy!

Gdzieś po drodze zauważyłem, że część powtarzających się zestawów metod nie jest objętych interfejsami. Jak wiadomo, dorzucenie interfejsu pomaga w standaryzacji kodu i tworzy nam wewnętrzny język wewnątrz aplikacji (wystarczy powiedzieć, że klasa implementuje taki a taki interfejs, i wiadomo już czego dalej możemy się spodziewać). Metody, które objąłem interfejsami to kolejno: getPosition(...), getSize(...), getPointOnGrid(...), getSizeOnGrid(...), setGrid(...) oraz snapToGrid(...). Powstały zatem takie interfejsy:

class LocatableInterface {

public:

    virtual sf::Vector2f getPosition()=0;
    virtual sf::Vector2u getSize()=0;
};
class LocatableOnGridInterface {

public:

    virtual sf::Vector2i getPointOnGrid()=0;
    virtual sf::Vector2u getSizeOnGrid()=0;
};
class GridableInterface {

public:

    virtual void setGrid(std::shared_ptr<Grid> grid) = 0;
    virtual void snapToGrid() = 0;
    virtual void snapToGrid(sf::Vector2i pointOnGrid) = 0;
};

Może nazwy nie są dobrane jakoś idealnie, ale jak wpadnę na lepszą, to moje IDE jest w stanie pomóc mi szybko to zmienić.

No, teraz śmiało mogłem brać się za dorzucenie nowego handlera dla elementów tła.

Wygoda jednego udręką drugiego…

W iteracji #6 pojawiły się elementy tła, tzw. figury złożone z kilku obiektów klasy StaticTile. Dla własnej wygody założyłem sobie, że pozycja podawana w kodzie (pozycja na siatce) będzie punktem zaczepu lewego dolnego rogu figury. Było to dla mnie bardziej jasne, ponieważ jak tworzyłem chociażby obiekt wzgórza, to prawie, jakbym kładł go na jakimś niesprecyzowanym podłożu. Przynajmniej takie wrażenie miałem - pomagało mi to wizualnie.

Niestety, to rozwiązanie miało jedną konkretną wadę - podczas przeliczeń tej pozycji nie mogłem w prosty sposób dodawać do siebie dwóch wektorów, bo oś Y zawsze zachowywała się w nieco inny sposób niż oś X. Do osi Y czasami trzeba było dodać (a czasami odjąć) wartość wysokości figury, ponieważ domyślnym punktem zaczepu był lewy górny róg obiektu. Zwłaszcza dało mi się to we znaki podczas przesuwania figury. Popatrzyłem w niedaleką przyszłość i stwierdziłem, że tak na prawdę ten zaczep do lewego dolnego rogu zaraz nie będzie mi potrzebny, bo niedługo zrezygnuję w ogóle z manualnego definiowana pozycji w kodzie. A wtedy już będzie mi obojętne, który punkt będzie punktem zaczepu figury.

Tak więc, koniec końców, poprawiłem ten mechanizm. Jak się okazało - tyle rzeczy po drodze się uprościło, że hej! :) Tak więc, ostatecznie wyszło na DUŻY PLUS.

Testów mi brak…

Przygoda związana z punktem zaczepu ma swoją dalszą historię. Kiedy twardo jeszcze broniłem własnego pomysłu, stwierdziłem że skoro nie mogę zdebugować tych wszystkich błędogennych przypadków (a było ich kilka), to napiszę testy, które mi będą pilnowały wszystkiego. Niestety - biję się w pierś - wyprodukowałem tak nietestowalny kod, że głowa mała. Nie dość, że sporo elementów ma dużo zależności, które musiałbym jakoś w testach konfigurować, to jeszcze obsługa chociażby kursora myszy, czy kontenerów na tilesy i figury to… niemockowalne funkcje statyczne. Argh… jak ja mogłem do tego doprowadzić? Cóż, wiadomo nad czym będę się skupiał dalej. Niestety (albo stety) projekt rozrasta się w bardzo szybkim tempie (to już ponad 3.500 linijek kodu! :) ). Nie ma to-tamto. Testy muszą być, bo scenariuszy do klikania będzie tylko więcej i więcej…

Nowa funkcjonalność - przesuwane elementy tła

Yay! Mamy to! Nowy ficzer! :) Po tych wszystkich refactoringach przyszedł czas na fizyczną nowość. Teraz już wszystko było proste. Obsługa wydarzeń kursora to dodanie odpowiedniego EventHandler-a. Zmiana pozycji figury - to jedynie znalezienie wektora różnicy pomiędzy starą a nową pozycją kursora myszy i dodanie jej do pozycji każdego elementu StaticTIle. Obsługa ramki (którą de facto zrobiłem jako pierwszą) również nie była jakaś skomplikowana. Jedynie odrobinę pomęczyłem się z miejscem lądowania figury - ale nie jakoś specjalnie. Cieszę się, że tak szybko to poszło.

Tutaj mamy kod naszego EventHandlera:

void FigureEventHandler::handleEvents(Keyboard &keyboard, Cursor &cursor) {
    if (cursor.isMouseMoved()) {
        auto registeredDragOnFigures = figureEventRegistry->getRegisteredDragOnFigures();
        for (auto &dragOnFigure : registeredDragOnFigures) {
            dragOnFigure->drag();
        }
    }

    auto figures = ObjectRegistry::getFigures();
    for (auto &figure : figures) {
        performHover(cursor, figure);
    }

    for (auto &figure : figures) {
        performDragDrop(cursor, figure);
    }
}

void FigureEventHandler::performHover(Cursor &cursor, std::shared_ptr<Figure> &figure) {
    if (cursor.isOver(figure) && !figureEventRegistry->isOverRegistered(figure)) {
        figureEventRegistry->registerOver(figure);
        figure->mouseEnter(animationPerformer);
    } else if (cursor.isOver(figure)) {
        figure->mouseOver(animationPerformer);
    } else if (figureEventRegistry->isOverRegistered(figure)) {
        figureEventRegistry->unregisterOver(figure);
        figure->mouseLeave(animationPerformer);
    }
}

void FigureEventHandler::performDragDrop(Cursor& cursor, std::shared_ptr<Figure> &figure) {
    if (cursor.isOver(figure) && figureEventRegistry->isOverRegistered(figure)) {
        bool isLeftClick = cursor.getClickType() == sf::Mouse::Button::Left;
        if (cursor.isClick() && !figureEventRegistry->isDragRegistered(figure) && isLeftClick) {
            figure->startDrag(animationPerformer);
            figureEventRegistry->registerDrag(figure);
        } else if (!cursor.isClick() && figureEventRegistry->isDragRegistered(figure)) {
            performDrop(cursor, figure);
        }
    } else if (figureEventRegistry->isOverRegistered(figure) && figureEventRegistry->isDragRegistered(figure)) {
        performDrop(cursor, figure);
    }
}

void FigureEventHandler::performDrop(Cursor &cursor, std::shared_ptr<Figure> &figure) {
    figureEventRegistry->unregisterDrag(figure);
    figure->drop(animationPerformer);
}

A tutaj implementacja naszej funkcjonalności:

void Figure::startDrag(std::shared_ptr<AnimationPerformer> animationPerformer) {
    isDraggingFlag = true;
    frame.setOutlineColor(sf::Color(255, 255, 0, 128));
    frame.setFillColor(sf::Color(255, 255, 0, 20));

    grid->turnHighlightOn(getSizeOnGrid());

    calculateDragOffset();
    recalculateHighlightPosition();
    moveTiles();
}

void Figure::calculateDragOffset() {
    auto cursorPosition = Cursor::getCurrentPosition();
    dragOffset = cursorPosition - getPosition();

    auto dragOffsetOnGrid = grid->positionToPointOnGrid(dragOffset);
    dragOffsetOnGrid = getPointOnGrid() - dragOffsetOnGrid;
    dragOffsetForHighlight = getPosition() - grid->pointOnGridToPosition(dragOffsetOnGrid);
}

void Figure::drag() {
    recalculateHighlightPosition();
    recalculateFramePosition();

    moveTiles();
    position = frame.getPosition();
}

void Figure::recalculateHighlightPosition() {
    auto highlightPosition = Cursor::getCurrentPosition();
    highlightPosition -= dragOffsetForHighlight;

    grid->setHighlightPosition(highlightPosition);
}

void Figure::recalculateFramePosition() {
    auto newFramePosition = Cursor::getCurrentPosition();
    newFramePosition -= dragOffset;
    frame.setPosition(newFramePosition);
}

void Figure::moveTiles() {
    auto oldPosition = position;
    auto currentPosition = frame.getPosition();
    auto positionDiff = currentPosition-oldPosition;

    for (auto &tile : tiles) {
        auto tileCurrentPosition = tile->getPosition();
        tileCurrentPosition += positionDiff;
        tile->setPosition(tileCurrentPosition);
    }
}

void Figure::drop(std::shared_ptr<AnimationPerformer> animationPerformer) {
    isDraggingFlag = false;

    pointOnGrid = grid->getHighlightPointOnGrid();
    position = grid->pointOnGridToPosition(pointOnGrid);
    grid->turnHighlightOff();

    createFrame();
    snapToGrid();
    dragOffset = {0.0f, 0.0f};
    dragOffsetForHighlight = {0.0f, 0.0f};
}

Prawda, że proste?

Dostałem prezent! :)

Nie wiem czy wiecie, ale Jetbrains - jeden z twórców najlepszych na świecie IDE - rozdaje darmowe (roczne!) licencje na swoje produkty. Jest kilka warunków, które należy spełnić. Są to m.in.:

  • Licencję otrzymujemy dla konkretnego projektu.
  • Tworzony projekt musi być niekomercyjnym projektem otwartoźródłowym
  • Projekt musi istnieć conajmniej 3 miesiące i musi być regularnie (aktywnie) rozwijany

Jeżeli Wasz projekt spełnia te punkty, to możecie odezwać się do działu zajmującego się licencjami. Więcej informacji na ten temat znajdziecie pod tym linkiem.

Skorzystam z okazji i pochwalę się:

Licencja CLion

Jak widać, to działa! :) Jest to dla mnie kolejny motywator, bo CLion jest na prawdę świetny i praca z nim to czysta przyjemność.

Bieżący efekt

Ten gif mogę odlądać godzinami… a Wy?

Ruchome tło

Czego nie udało się zrobić?

No niestety, nie udało mi się ogarnąć jeszcze zmiany rozmiarów elementów tła, o której pisałem conieco poprzednio. Może to i lepiej, bo wiem, że to co teraz jest zrobione - jest zrobione dobrze. Bez pośpiechu, za to z refleksją.

Plany na następną iterację

No tak, przesuwanie puzelków jest… Przesuwanie figur jest… zatem… jedziemy dalej z tematem! Lista rzeczy do zrobienia jest bardzo długa. Na szczęście cały projekt mam w swojej głowie i wiem, co i jak dalej. W najbliższej iteracji:

  1. Poprawa bugów - bo to trzeba robić w miarę na bieżąco :)
  2. Testy, testy, testy! :) Trzeba przetestować kilka drobiazgów, abym miał pewność że te mniej pewne elementy systemu mnie nie zawiodą.
  3. Prosty system kolizji. Nie chcę, aby elementy tła na siebie nachodziły. Powoduje to więcej sytuacji do obsłużenia, a jak wiadomo - łatwo w takich sytuacjach o kolejne bugi.
  4. Skalowanie elementów tła. Bo chmurka może być… szersza! :)

Wygląda na dużo, ale kod w obecnej chwili jest na tyle poukładany, że wręcz pływam w nim :)

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.