SFML-owe zabawy #3 - Skalowanie i przeciąganie


2018-07-26, 00:00

Z drobnym opóźnieniem urlopowym zapraszam na nowy wpis z serii SFML-owe zabawy! :) Dzisiaj opowiemy sobie trochę o skalowaniu zawartości okna i procesie drag-n-drop. Miłego czytania! :)


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


Jakie były plany na tą iterację?

Podczas bieżącej iteracji próbowałem skupić się na pięciu punktach:

  1. Instrukcja budowania projektu
  2. Skalowanie okna (prawidłowe działanie)
  3. Drag-n-Drop na puzelku
  4. Wprowadzenie easing-u do wydarzeń na puzelku
  5. Mruganie puzelka

Wydawało się względnie niewiele (słowo klucz: wydawało się).

Co udało się zrobić?

Bieżącą iterację udało mi się zrobić w połowie. Nie znaczy to jednak, że była to klapa! Okazało się, że pojawiło się sporo scenariuszy, na których debugowanie trzeba było znaleźć czas. A poprawianie takich scenariuszy na prawdę sporo uczy :)

Jest instrukcja! :)

Marna… ale jest :) Z każdą iteracją będę próbował dorzucić coś, co będzie pomagało we wspieraniu projektu przez innych. Póki co jest informacja:

  • o pierwszym scenariuszu, który implementujemy
  • jak zbudować projekt
  • obrazek ze statusem budowania przez TravisCI na branchu master
  • informacje kontaktowe

Dodatkowo, jest też informacja o tym, że szukam kogoś kto chciałby postawić projekt na Windows-ie i napisać howto. Może ktoś coś…? :)

Po szczegóły zapraszam do repozytorium projektu.

Fullscreen

Trochę niezamierzonym celem stała się obsługa trybu pełnoekranowego. Po naciśnięciu klawisza F możemy przełączyć się z trybu okienkowego do pełnego ekranu. W SFML-u robi się to bardzo prosto. Przypomnijmy sobie tworzenie okna gry:

window = std::make_shared<sf::RenderWindow>(
    sf::VideoMode(windowedWidth, windowedHeight), title, sf::Style::Default
);

Trzecim parametrem konstruktora klasy sf::RenderWindow jest zestaw flag związanych ze stylem okna. Przez podanie tam wartości sf::Style::Default tworzymy okno z ramkami (tryb okienkowy). Aby już na samym początku wejść w tryb pełnoekranowy, należy tą wartość zamienić na sf::Style::Fullscreen.

Przełączanie trybów ekranowych

SFML daje nam możliwość zmiany parametrów okna. Mamy funkcje setTitle(), setPosition(), setSize(). Nie znalazłem za to funkcji zmieniającej styl okna - za pomocą której moglibyśmy włączyć tryb pełnoekranowy. Czyli to już koniec? Właśnie że nie! Przecież udało mi się to w jakiś sposób zrobić :) Aby ten cel osiągnąć, trzeba poprosić okno, aby utworzyło ono nowe okno, a siebie zamknęło. Mniej więcej tak to wygląda:

sf::VideoMode Game::findHighestResolutionMode() {
    auto modes = sf::VideoMode::getFullscreenModes();
    auto maxHeightMode = modes[0];
    for (int i=0; i<modes.size(); i++) {
        if (modes[i].height > maxHeightMode.height) {
            maxHeightMode = modes[i];
        }
    }
    return maxHeightMode;
}

void Game::handleKeyboardEvents() {
    ...
    std::string title = "Mario::Edit";
    sf::VideoMode mode = findHighestResolutionMode();
    window->create(mode, title, sf::Style::Fullscreen);
    ...
}

W skrócie: pobieram tryb z najwyższą rozdzielczością (bo czemu nie?) i tworzę nowe okno w tym trybie. W dokumentacji funkcji window->create() można przeczytać, że:

If the window was already created, it closes it first.

Zatem, wszystko się zgadza :)

Skalowanie zawartości okna

Tak jak pisałem w poprzednim wpisie, miałem pomysł na skalowanie zawartości okna. Przyjąłem, że będę dostosowywał się do jego wysokości. Jeżeli wysokość okna nie zmieni się to nie zmieniam skali. Takie rozwiązanie wiąże się z tym, że jeżeli zmieni się jedynie szerokość okna - będzie trzeba pokazać więcej jego zawartości. Analizę procesu skalowania chyba najlepiej zacząć od miejsca zmiany rozmiaru okna:

void Game::handleSystemEvents() {
    sf::Event event;
    while (window->pollEvent(event)) {
        switch (event.type) {
            ...
            case sf::Event::Resized: {
                width = event.size.width;
                height = event.size.height;

                sf::Vector2u newSize(width, height);
                axis.rescale(newSize);
                window->setView(sf::View(sf::FloatRect(0, 0, width, height)));
            } break;
            ...
        }
    }
}

Gdyby nie wywołanie axis.rescale(newSize), to całość wyglądałaby tak jak na obrazku z poprzedniego wpisu:

Fajny resize

W skrócie: poprzednio zwiększalibyśmy tylko viewport, czyli wielkość sceny. Skala (a wraz z nią rozmiar elementów) zostawała bez zmian. W chwili obecnej przeliczanie skali odbywa się wewnątrz klasy (niefortunnie nazwanej) Axis:

class Axis {

public:

    void rescale(sf::Vector2u windowSize);

    static float getScale();

private:

    const std::size_t blocksOnHeight = 12;
    const std::size_t blockHeight = 16;
    static float scale;

};

...

float Axis::scale = 1;

void Axis::rescale(sf::Vector2u windowSize) {
    float prevScale = Axis::scale;
    scale = (float)windowSize.y / (float)(blockHeight*blocksOnHeight);

    if (prevScale == Axis::scale) {
        return;
    }

    float scaleRatio = Axis::scale/prevScale;

    auto tiles = TileRegistry::getAll();
    for (int i=0; i<tiles.size(); i++) {
        auto tile = tiles.at(i);
        auto position = tile->getPosition();
        tile->setPosition(position.x*scaleRatio, position.y*scaleRatio);
    }
}

Tak tak, SRP tutaj kuleje. Cóż, będzie trzeba to poprawić. Nie ma jednak tego złego: jest to za to odpowiednie miejsce, by wspomnieć o tym, że istnieje takie coś jak S.O.L.I.D. - kilka dobrych rad programistycznych, które propaguje wujek Bob. Może jest tutaj ktoś, kto nie zna tematu? Mocno zachęcam do googlowania, bo warto.

Wracając do kodu - funkcja Axis::rescale() ma dwie odpowiedzialności. Po pierwsze, jak nazwa wskazuje - przelicza skalę wykorzystywaną w teksturowaniu. Po drugie (niestety) - na nowo rozmieszcza przeskalowane elementy w oknie. Gdzie jednak jest skalowanie naszego puzelka? No… tam, gdzie go tworzymy:

std::shared_ptr<Tile> TileSet::createTile(std::size_t x, std::size_t y)
{
    sf::Sprite sprite;
    sprite.setTexture(*(this->texture));
    sprite.setScale(Axis::getScale(), Axis::getScale());     // <--- Tutaj skalujemy

    sf::IntRect textureRect;
    textureRect.width = this->tileWidth;        // <--- Tutaj rozmieszczamy teksturę
    textureRect.height = this->tileHeight;

    textureRect.top = (y * (this->tileHeight+separatorY)) + this->offsetY;
    textureRect.left = (x * (this->tileHeight+separatorX)) + this->offsetX;
    sprite.setTextureRect(textureRect);

    auto tile = std::make_shared<Tile>(sprite);
    ....

    return tile;
}

Puzelek tworzymy tylko raz, na początku. Jednak trzeba z każdą ramką przekazać do niego aktualną skalę, aby ten dobrze się narysował:

void Tile::rescale(float scaleX, float scaleY) {
    this->scaleX = scaleX;
    this->scaleY = scaleY;
    this->sprite.setScale(scaleX*scalePromotion, scaleY*scalePromotion);
}

Na szybko: scalePromotion to jest mnożnik, dzięki któremu mogę sterować wielkością elementu (np. przy obsłudze wydarzeń MouseEnter oraz MouseLeave).

Przeciąganie puzelka

W tym miejscu najpierw należy powiedzieć sobie, czym jest proces przeciągania elementu. W moim rozumieniu jest to jego reakcja na trzy wydarzenia: rozpoczęcie przeciągania - StartDrag, trwanie przeciągania - Drag i zakończenia przeciągania - Drop.

Wydarzenie StartDrag jest emitowane podczas kliknięcia kursorem znajdującym się nad przeciąganym elementem. Tutaj rozpoczyna się proces przeciągania: wyróżnienie puzelka (półtransparentny kolor biały) oraz rejestrowanie miejsca uchwytu (dragOffset). W kodzie wygląda to tak:

void Tile::startDrag() {
    sprite.setColor(sf::Color(255, 255, 255, 180));

    auto cursorPosition = Cursor::getCurrentPosition();
    dragOffset.x = cursorPosition.x - sprite.getPosition().x;
    dragOffset.y = cursorPosition.y - sprite.getPosition().y;
    this->drag();
}

Wydarzenie Drag zachodzi co klatkę od momentu, kiedy zostało zarejestrowane wydarzenie StartDrag, ale nie zostało zarejestrowane wydarzenie Drop. W tym miejscu modyfikujemy pozycję elementu przeciąganego. Tak wygląda kod realizujący tą operację:

void Tile::drag() {
    auto cursorPosition = Cursor::getCurrentPosition();
    cursorPosition -= this->dragOffset;
    sprite.setPosition(cursorPosition.x, cursorPosition.y);

    this->correctCorners();
}

Poniżej znajduje się również ilustracja wyjaśniająca:

Drag-n-drop

Tip: Metoda correctCorners() służy za pilnowanie, by przeciągany element nie wyszedł poza ekran.

Wydarzenie Drop zachodzi w momencie, kiedy zarejestrowane jest wydarzenie StartDrag i klawisz myszy zostaje zwolniony. Tutaj jest miejsce na porządki oraz usunięcie wyróżnienia elementu przeciąganego. Implementacja:

void Tile::drop() {
    sprite.setColor(sf::Color(255, 255, 255, 255));

    dragOffset = {0, 0};
}

Efekt na dziś

Póki co mamy wyróżnienie puzelka podczas najazdu na niego kursorem, obsługę przeciągania go oraz skalowanie zawartości okna. Coraz fajniej to wygląda :) Zobaczcie sami!

Poprawny resize

Uwaga. Przepraszam, za rozmiar gifa. Na pocieszenie, 10MB to efekt kompresji pliku 37MB :)

Czego nie udało się zrobić?

Jak to często bywa - niedoszacowałem. Nie widzę jednak w tym nic dziwnego - z SFML-em nie miałem za dużo wspólnego i czasami błądzę, zanim znajdę właściwe rozwiązanie problemu. Tak też nie udało się zająć zarówno wdrożeniem easing-u jak i mruganiem puzelka. Plusem w tej sytuacji jest to, że chociaż nie zaimplementowałem tych rozwiązań, to mam już na to plan.

Plany na następną iterację

Jak zwykle, zaczynamy od tego, co zostało. Na pierwszy ogień pójdzie zatem wdrożenie easing-u przy wyróżnianiu puzelka oraz mruganie. Dalej zajmiemy się stworzeniem siatki, wzdłuż której układać będzie można elementy świata. Rasumując:

  1. Wdrożenie easing-u
  2. Mruganie puzelka
  3. Układanie elementów wzdłóż siatki

Nie ma tego wiele, zatem powinno być git. :)


¹ Mógłbym na spokojnie skończyć tą iterację i dopiero wtedy napisać posta, ale po pierwsze post pojawiłby się później. Po drugie, powstałby zbyt długi wpis i niewiele osób doszło by do jego końca.



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.