SFML-owe zabawy #4 - Grid i mruganie


2018-08-16, 00:00

Jest! Mamy to! :) Nowy wpis z serii SFML-owe zabawy, a wraz z nim przyciąganie mrugającego puzelka do linii siatki. Serdecznie zapraszam! :)


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


Refactor na początku

Na pierwszy strzał poszedł refactor. Zrobiłem sporo głupot i nieeleganckich rozwiązań, które nie powinny mieć miejsca. Było to związane przede wszystkim z moją nieznajomością klasy Vector2. Miałem w kodzie sporo miejsc, gdzie wysyłałem parametry x oraz y, zamiast podesłać wektorek. W innych partiach kodu, wektor mnożyłem w dwóch linijkach, a mogłem przemnożyć go jeden raz. W oczy kuły również miejsca, w których przykładowo z Vector2u przechodziłem na Vector2f (tak, dwa rzutowania). Czasami warto przeglądnąć swój kod :)

Uczymy się pracy z Vector2

Klasa typu Vector2 (dokumentacja tutaj) to najprostsza w swojej postaci implementacja klasy często nazywanej Point w dwóch wymiarach. Posiada ona dwa publiczne pola x oraz y, typu T przekazywanego jako parametr szablonu. Biblioteka SFML oprócz implementacji szablonowej dostarcza nam kilka aliasów dla najczęściej używanych typów:

  • Vector2f jako alias dla Vector2<float>
  • Vector2i jako alias dla Vector2<int>
  • Vector2u jako alias dla Vector2<unsigned int>

Dzięki swojej prostocie wektory są niesamowicie użyteczne. Dodatkowo, dzięki przeciążeniu odpowiednich operatorów możemy poddawać je prostym operacjom, co powoduje, że kod staje się bardziej czytelny. Zaznaczyć jednak muszę, że operatory te działają jedynie w obrębie tych samych typów.

Operacje Vector2 kontra Vector2

Najprostszą operacją jest suma dwóch wektorów. Poniższy kod

v.x += v2.x;
v.y += v2.y;

możemy spokojnie zamienić na:

v += v2;

To samo tyczy się odejmowania wektorów od siebie. Bardzo przydatne przy przesuwaniu punktów. Niestety, nie przemnożymy ani nie podzielimy w taki sposób dwóch wektorów. Pamiętać należy, że obydwa wektory muszą być tego samego typu.

Operacje Vector2 kontra skalar

Niestety, nie dodamy ani nie odejmiemy od wektora liczby. Możemy za to przemnożyć oraz podzielić wektor przez liczbę. Zatem poniższy kod

v.x *= 2;
v.y *= 2;

bez wyrzutów sumienia zamieniamy na:

v *= 2;

Operacje mnożenia często przydają się przy skalowaniu zawartości okna, o czym pisałem w poprzednim wpisie. Pamiętać należy, że operatory mnożenia i dzielenia działają w obrębie typu przekazywanego do szablonu. Zatem dla Vector2f dzielnikiem musi być stała typu float, dla Vector2i będzie nim int, a dla Vector2u jest to unsigned int.

Zmiana typu wektora

W kilku miejscach miałem takiego potworka:

Vector2u vu;
Vector2f vf;
// ... duzo kodu tutaj

vu.x += ((unsigned int)vf.x) * scale;
vu.y += ((unsigned int)vf.x) * scale;

Oczywiście, to tylko prosty przykład tego co się działo u mnie w kodzie. Kto wie, jak używać gita, ten z łatwością znajdzie oryginał. Powyższy kod można było spokojnie zmienić na coś takiego:

Vector2u vu;
Vector2f vf;
// ... duzo kodu tutaj

Vector2u vu2(vf);
vu += vu2*scale;

Nie ładniej? Zapamiętać należy, że możemy manipulować typem wektora przez użycie konstruktora. Zgodnie z dokumentacją:

Vector2 (const Vector2< U > &vector)
Construct the vector from another type of vector.

W takim razie takie podejście zalecają nam również sami twórcy biblioteki. Ekstra! :)


Uwaga. Pamiętajmy, że konwersja może powodować utratę części informacji o wartościach przechowywanych w wektorze.


Może wiedza na temat klasy Vector2 nie jest jakaś skomplikowana. Jednak skoro ja robiłem rażące błędy, to całkiem możliwe, że inni również mogą mieć z tym problemy.

Obsługa siatki

Obsługa siatki to było dla mnie wielkie, bardzo pozytywne zaskoczenie. Borykałem się z kilkoma bugami w skalowaniu i przeciąganiu, które trzeba było łatać dziwnymi warunkami. Kiedy zaimplementowałem przyciąganie puzelka do siatki - wszystkie problemy zniknęły! Wow. Tyle dziwnego kodu poszło w zapomnienie :)

Lecimy z kodem. Samą funkcjonalność siatki można podzielić na dwie części: rysowanie i przyciąganie puzelka. Implementacja obydwu części znajduje się w klasie Grid. Do konstruktora przekazuję rozmiary okna, aby na ich podstawie móc obliczyć takie rzeczy jak grubość linii oraz odległość linii od siebie. Operacje te wydzieliłem jednak do osobnej metody Grid::rescale(...), która przydaje się również przy zmianie rozmiarów okna.

Zawartość tej metody wygląda następująco:

void Grid::rescale(sf::Vector2u windowSize) {
    this->windowSize = windowSize;

    lineThickness = windowSize.y / lineThicknessDivider;
    distance = windowSize.y / rows;
}

Opieram się tutaj w całości na wysokości okna. Grubość linii wyznaczam tak, aby przy najmniejszym możliwym rozmiarze okna pojawiała się linia w wielkości 1px. Do tego zkładam, że w pionie powinno zmieścić się mniej więcej 12 komórek (pole rows).

Rysowanie

Rysowanie siatki odbywa się w głównej pętli w klasie Game, przed rysowaniem tilesów. Wszystko odbywa się w funkcji Grid::draw(...). Rysowanie dzielimy na trzy etapy: rysowanie linii poziomych, pionowych oraz - w zależności od tego czy jesteśmy w trakcie przeciągania puzelka czy nie - drop-pointu. Implementacja rysowania każdego z elementów jest bardzo prosta, więc przeanalizujcie ją sobie sami :)

void Grid::drawHorizontalLine(
    sf::Uint32 number, std::shared_ptr<sf::RenderWindow> window
) {
    sf::Uint32 posY = number*distance;

    sf::RectangleShape line;
    line.setPosition(sf::Vector2f(0, posY-lineThickness/2));
    line.setFillColor(lineColor);
    line.setSize(sf::Vector2f(windowSize.x, lineThickness));

    window->draw(line);
}

void Grid::drawVerticalLine(
    sf::Uint32 number, std::shared_ptr<sf::RenderWindow> window
) {
    sf::Uint32 posX = number*distance;

    sf::RectangleShape line;
    line.setPosition(sf::Vector2f(posX-lineThickness/2, 0));
    line.setFillColor(lineColor);
    line.setSize(sf::Vector2f(lineThickness, windowSize.y));

    window->draw(line);
}

void Grid::drawHightlight(
    std::shared_ptr<sf::RenderWindow> window
) {
    sf::RectangleShape line;
    line.setPosition(highlightPosition);
    line.setFillColor(lineColor);
    line.setSize(sf::Vector2f(distance, distance));

    window->draw(line);
}

Przyciąganie

Mechanizm przyciągania puzelka do siatki zaimplementowany jest na przestrzeni mechanizmu drag-drop. Jak możecie zauważyć na obrazku, podczas przeciągania elementu na siatce pojawia się zamalowane “okienko”. Punkt tego obiektu (jego lewy górny róg) obliczany jest na podstawie koordynatów kursora myszy za pomocą metody Grid::getPointOnGrid(...):

sf::Vector2f Grid::getPointOnGrid(sf::Vector2f pointOnScreen) {
    sf::Vector2f retval = {0, 0};
    retval.x = ((int)(pointOnScreen.x/distance))*distance;
    retval.y = ((int)(pointOnScreen.y/distance))*distance;
    return retval;
}

No ładnie… I tutaj właśnie jest kolejny z potworków do refactoru. Wracając jednak do tematu: korzystamy z dzielenia całkowitego, które powoduje utratę informacji o części dziesiętnej. Następnie mnożymy powstałą wartość z powrotem przez dzielnik, co daje efekt znalezienia najbliższego (lewego górnego) punktu na siatce. Dzielnikiem (oraz mnożnikiem) tutaj jest szerokość okienka na gridzie.

Drop-point obliczamy w momencie przeciągania obiektu. Jego wartość wykorzystujemy w metodzie Tile::drop(), podstawiając ją w miejsce koordynatów puzelka. Mechanizm bardzo prosty, w bardzo znaczący sposób wpłynął na liczbę pojawiających się w kodzie bugów (oczywiście w pozytywnym tego słowa znaczeniu).

Mruganie, czyli dodatkowy wątek

Ostatnim punktem dzisiejszego wpisu jest coś, co nadało chyba największego dotychczas smaku całości: mruganie puzelka.

Postanowiłem, że do mrugania użyję osobnego wątku. Przede wszystkim nowy wątek wykonuje znacznie mniej kodu. Mam w takim razie gwarancję że nie będę czekał na wykonanie się obrotu pętli więcej, niż czas przeze mnie określony. Odpada mi zatem sprawdzanie mnóstwa warunków brzegowych związanych z upływem czasu. Drugą zaletą tego rozwiązania jest fakt, że kod staje się bardziej poukładany.

Przejdźmy do kodu. Jak pewnie się spodziewacie - animacja mrugania zakotwiczona jest w klasie Game, tuż przed główną pętlą. Mam na myśli ten skrawek kodu:

SpecialBlockBlinkingAnimation blinkAnimation(tiles);
blinkAnimation.run();

Kod realizujący abstrakt animacji wygląda następująco:

void Animation::run() {
    sf::Clock clock;
    sf::Int32 startMilliseconds = clock.getElapsedTime().asMilliseconds();

    std::thread interval([=]() {
        while (true) {
            sf::Int32 currentMilliseconds = clock.getElapsedTime().asMilliseconds();
            sf::Int32 animationPointInTime = (currentMilliseconds-startMilliseconds) % duration;

            for (std::size_t i=0; i<frames.size(); i++) {
                if (frames.at(i)->entersNow(animationPointInTime, duration)) {
                    frames.at(i)->enter();
                }
            }

            std::this_thread::sleep_for(std::chrono::milliseconds(sleepTime));
        }
    });
    interval.detach();
}

To w tym miejscu definiuję i uruchamiam nowy wątek. Ponieważ animacja puzelka tak na prawdę trwa w nieskończoność, zapuszczam tutaj pętlę nieskończoną, której obrót kończy się drzemką wątku trwającą ustaloną ilość czasu. W każdej iteracji puszczam pętlę po wszystkich ramkach, po czym uruchamiam tą ramkę, która ma wejść w tej chwili.

To jest implementacja klasy bazowej, po której dziedziczyć będzie każda animacja. Jak wygląda zatem klasa dziedzicząca? Odpowiedź znajduje się poniżej :)

SpecialBlockBlinkingAnimation::SpecialBlockBlinkingAnimation(
    std::vector<std::shared_ptr<Tile>> tiles
) {
    duration = 1000;

    addFrame(std::make_shared<AnimationFrame>(0, 70, [=]() {
        for (std::size_t i=0; i<tiles.size(); i++) {
            if (!tiles.at(i)->isDragging()) {
                tiles.at(i)->change(0, 5);
            }
        }
    }));

    addFrame(std::make_shared<AnimationFrame>(70, 80, [=]() {
        for (std::size_t i=0; i<tiles.size(); i++) {
            if (!tiles.at(i)->isDragging()) {
                tiles.at(i)->change(1, 5);
            }
        }
    }));

    addFrame(std::make_shared<AnimationFrame>(80, 90, [=]() {
        for (std::size_t i=0; i<tiles.size(); i++) {
            if (!tiles.at(i)->isDragging()) {
                tiles.at(i)->change(2, 5);
            }
        }
    }));

    addFrame(std::make_shared<AnimationFrame>(90, 100, [=]() {
        for (std::size_t i=0; i<tiles.size(); i++) {
            if (!tiles.at(i)->isDragging()) {
                tiles.at(i)->change(1, 5);
            }
        }
    }));
}

Tak, zgadza się! Mruganie, to nic innego jak podstawianie odpowiedniej grafiki w odpowiednim czasie! :) Każda ramka dostaje swój przedział czasowy w postaci procentowej względem czasu trwania całości animacji (duration = 1000;). Do tego dochodzi lambda, która podczas uruchamiania zmienia grafikę we wszystkich obiektach typu Tile.

Efekt

Jest coraz ładniej! :) Aż programować się chce! :)

Grid i mruganie

Plany na następną iterację

Dwie trzecie planów na tą iterację za nami. Dzisiejszy wpis utwierdził mnie w przekonaniu, że to najprostsze rozwiązania dają najlepszy efekt. Nastepną iterację będę chciał w całości poświęcić tematowi easing-u. Niby nic skomplikowanego, ale myślę, że będzie o czym pisać. Zatem, 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.