SFML-owe zabawy #12 - Klikaj i zwyciężaj ponownie!


2019-01-11, 00:00

Nasz Mario::Edit coraz bardziej przypomina edytor! Yay! :) Dziś pojawiła się druga belka, z której ściągać możemy krzaczki, chmurki i wzgórza! Powolutku kończymy pracować nad edytorem, niebawem bierzemy się za grę!


Uwaga. Wszystko, o czym piszę poniżej znajduje się na branchu iteration-11 w repozytorium projektu, o tutaj: https://github.com/CppPolska/MarioEdit/tree/iteration-11. 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?

Po pierwsze i najważniejsze: mamy nową funkcjonalność! :) Możemy już dokładać tyle elementów tła, ile dusza zapragnie. Po drugie, popracowałem nieco nad elastycznością kodu, co mam nadzieję zwróci się w następnych iteracjach.

Wydzielania klas ciąg dalszy

Dzięki ciągłemu refactoringowi mogę zauważyć, że klasa Grid sprzed trzech iteracji miała aż trzy dodatkowe odpowiedzialności. Nie jest łatwo je wydzielić, ale powoli coś udaje się z tym fantem zrobić.

Pierwsza z nich, to klasa Highlight, która nie wiadomo dlaczego tam siedziała. Druga, nieco bardziej ukryta, została wydzielona właśnie teraz - klasa Settings, czyli konfiguracja siatki. Poniżej zamieszczam publiczny interfejs tej klasy:

class Settings : public RescalableInterface {

public:

    static const sf::Uint32 Auto = 0;

    Settings(
        sf::Uint32 rows, sf::Uint32 cols, sf::Vector2f position, sf::Vector2f size
    );
    explicit Settings(float scale, sf::Vector2f position=sf::Vector2f(0, 0));

    sf::Uint32 getRows() const;
    sf::Uint32 getCols() const;
    sf::Vector2f getSize() const;
    sf::Vector2f getPosition() const;
    float getScale() const;

    void rescale(std::unique_ptr<Scale> &scale) override;

    sf::Uint32 getLineThickness() const;
    float getLineDistance() const;
    bool hasIncompleteEnding() const;

};

Jak możemy zobaczyć, mamy tutaj dwa konstruktory. Pierwszy dotyczy siatki rozpinającej się w tle. Możemy przekazać ilość wierszy i kolumn, ustawić pozycję siatki oraz jej rozmiar. Ten obiekt najczęściej przekazywany jest pomiędzy poszczególnymi komponentami. Drugi konstruktor używany jest do generowania buttonów znajdujących się w górnej belce. Mimo, że na buttonach siatka nie jest rysowana, to jednak musi zostać załączona ze względu na prawidłowe rozmieszczanie puzelków, z których się te figury składają.

Trzecia odpowiedzialność, którą mi się udało znaleźć (ale jeszcze nie została wydzielona), to algorytm rysowania siatki, który zasługuje na separację od tej klasy. Dlaczego? Ponieważ w kodzie korzystamy już z obiektów klasy Grid, które nigdy nie są rysowane.

Grid wraca jako shared_ptr

Tak to czasem bywa, że niektóre rozwiązania są dobre tylko na chwilę. Ostatnio zmieniłem wszystkie występowania std::shared_ptr<Grid> na std::unique_ptr<Grid>. Oczywiście, wszędzie gdzie przekazuję unique_ptr, to przekazywana jest tak na prawdę referencja - przez kopię unique_ptr nie można przekazywać ze względu na brak konstruktora kopiującego. Niestety, ale natrafiłem na przypadek, który spowodował, że musiałem wycofać się (chwilowo) z tego rozwiązania. Co to za przypadek? Już opisuję.

W roboczej wersji kodu miałem trzy klasy: Bush, Cloud oraz Hill, które służyły zarówno jako generator figury, jak i sama figura. Jako parametr przyjmowałem m.in. std::unique_ptr<Grid>. Główny pogląd na te klasy był następujący:

class Cloud {

public:
    Cloud(std::unique_ptr<Grid>& grid): grid(grid) {
        // ...
    }

private:
    std::unique_ptr<Grid>& grid;
}

Jak widać wyżej, pole grid musi zostać zainicjalizowane przez listę inicjalizacyjną konstruktora, ponieważ jest to referencja. Wszystko tutaj jest w porządku do momentu, kiedy stwierdziłem, że klasa Cloud może sama definiować własny obiekt klasy Grid, jeżeli takiego do niej nie przekażemy (poprzez użycie drugiego konstruktora, który nie przyjmuje parametru grid). Wyglądało to mniej więcej tak:

class Cloud {

public:
    Cloud(std::unique_ptr<Grid>& grid): grid(grid) {
        // ...
    }

    Cloud() {
        grid = std::make_unique<Grid>(...);  // bump, to nie zadziała!
    }

private:
    std::unique_ptr<Grid>& grid;
}

Bardzo zależało mi na tym rozwiązaniu. Potrzebowałem tego, aby móc rysować chmurkę na górnej belce. Pierwsze, co przyszło mi na myśl - z wielkim smutkiem oczywiście - to cofnięcie się do poprzedniej iteracji i zmiana unique_ptr<Grid> z powrotem na shared_ptr<Grid>.

Koniec tej historii jest taki, że ostatecznie zdecydowałem się wyrzucić klasy Bush, Cloud oraz Hill z projektu, a obiekt klasy Grid z powrotem czeka na refactoring do unique_ptr<Grid>. Cóż, programowanie to czasami prawdziwe pole walki! :)

Referencje do shared_ptr - zdecydowanie zły pomysł

Przedwczesna optymalizacja jest złem, o czym przekonałem się na własnej skórze. W kilku miejscach w kodzie zamiast klasycznego użycia shared_ptr<Grid> przez kopiowanie, wrzucałem referencje do przekazywanych przez funkcję obiektów. Po pewnym czasie odezwał się problem, na który zupełnie nie miałem pomysłu: przy drugim obrocie głównej pętli obiekt grid magicznie… znikał. Złapałem nawet dwa miejsca obok siebie, w których jedno jeszcze miało ten obiekt, a metoda odpalana na obiekcie już go nie miała. Normalnie niemożliwe, żeby shared_ptr, który jest przekazany do połowy obiektów w systemie tak po prostu sobie zniknął. Błędu szukałem… prawie tydzień. Po tygodniu poszukiwań postanowiłem cofnąć się w historii gita (commit po commicie), żeby znaleźć ostatnie miejsce w historii, kiedy to działało. Na szczęście udało mi się zlokalizować źródło problemu. Teraz jestm bogatszy o to doświadczenie :)

Dodatkowe logi developerskie

Nigdy nie jest tak, że błąd w kodzie wiąże się z samymi złymi konsekwencjami. To dzięki rozwiązywaniu problemów rodzi się doświadczenie :) Powyższa sytuacja z referencjami spowodowała, że dorzuciłem znacznie więcej logów wydarzeń w tych miejscach, w których faktycznie powinny się one znajdować. Po rozwiązaniu problemu poczyściłem odrobinę to, co napaskudziłem, ale - część z nich zostawiłem licząc na to, że skoro teraz się przydały, to przydadzą się i później.

Klasy z rodziny FigureGenerator

Kolejnym zestawem odpowiedzialności, który udało mi się wyrwać to algorytmy generujące figury. Od teraz możemy ich używać w większej ilości miejsc, niż elementy tła. Niedługo będziemy mogli generować flagę oraz rury, które będą nieco innymi obiektami niż te generowane obecnie. Publiczny interfejs takiego generatora jest następujący:

class AbstractFigureGenerator {

public:

    AbstractFigureGenerator(std::unique_ptr<TileFactory> &tileFactory, std::shared_ptr<Grid> grid);

    virtual std::vector<std::shared_ptr<StaticTile>> generate(
        sf::Vector2i pointOnGrid, sf::Uint8 size
    ) = 0;

    void updateGrid(std::shared_ptr<Grid> grid);
};

Jak widzimy, zależności mamy tylko dwie: klasa Grid, która reprezentuje siatkę oraz TileFactory, czyli generator puzelków. Tyle wystarcza, abyśmy mogli rysować obiekty złożone z więcej niż jednego puzelka.

A w taki sposób generujemy chmurkę:

#include "CloudGenerator.hpp"

#include "classes/Infrastructure/Log.hpp"

CloudGenerator::CloudGenerator(
    std::unique_ptr<TileFactory> &tileFactory, std::shared_ptr<Grid> grid
) : AbstractFigureGenerator(tileFactory, grid) {

}

std::vector<std::shared_ptr<StaticTile>> CloudGenerator::generate(
    sf::Vector2i pointOnGrid, sf::Uint8 size
) {
    Log::out("Generating cloud");

    std::vector<std::shared_ptr<StaticTile>> tiles;

    auto beginBottom = tileFactory->createStaticTile(0, 8, grid);
    beginBottom->snapToGrid(pointOnGrid);
    pointOnGrid.y--;
    tiles.push_back(beginBottom);

    auto beginTop = tileFactory->createStaticTile(0, 7, grid);
    beginTop->snapToGrid(pointOnGrid);
    pointOnGrid.x++;
    pointOnGrid.y++;
    tiles.push_back(beginTop);

    for (int i=0; i<size; i++) {
        auto middleBottom = tileFactory->createStaticTile(1, 8, grid);
        middleBottom->snapToGrid(pointOnGrid);
        pointOnGrid.y--;
        tiles.push_back(middleBottom);

        auto middleTop = tileFactory->createStaticTile(1, 7, grid);
        middleTop->snapToGrid(pointOnGrid);
        pointOnGrid.x++;
        pointOnGrid.y++;
        tiles.push_back(middleTop);
    }

    auto endBottom = tileFactory->createStaticTile(2, 8, grid);
    endBottom->snapToGrid(pointOnGrid);
    pointOnGrid.y--;
    tiles.push_back(endBottom);

    auto endTop = tileFactory->createStaticTile(2, 7, grid);
    endTop->snapToGrid(pointOnGrid);
    tiles.push_back(endTop);

    return tiles;
}

Nowy mechanizm klikania v2.0

Jesteśmy o jeden krok do przodu! Poza potyczkami związanymi z generowaniem buttonów, reszta była tylko formalnością. Szlaki miałem już przetarte, bo od dłuższej chwili mieliśmy już jedną belkę. Jest jednak jedna różnica pomiędzy mechaniką ściągania puzelków oraz ściąganiem figur. Tilesy są elementarnymi obiektami, z których możemy budować większe elementy, dlatego możemy układać je masowo za pomocą jednego kliknięcia. Figury w swoim zastosowaniu różnią się od puzelków tym, że nie konstruuje się z nich nic nadrzędnego. Dlatego postanowiłem wyłączyć możliwość ich masowego układania.

Aktualny efekt

Po raz kolejny - jest pięknie. Już na prawdę niewiele brakuje, aby móc powiedzieć, że jest z górki. Patrzcie!

Klikaj i zwyciężaj po raz drugi!

Czego nie udało się zrobić?

No, niełatwo było - o tym dowiedzieliście się powyżej. Jedyne, czego nie udało mi się zrobić to testy, które od kilku iteracji spycham na sam koniec. Jak nam wszystkim wiadomo - tak nie powinno być, ale… no, nie wyrobiłem się. Na szczęście projekt nie jest na tyle skomplikowany, by to powodowało jakikolwiek większy problem :)

Plany na następną iterację

Na starcie zamierzam usprawnić nieco dwa mechanizmy - układanie puzelków oraz figur tak, aby lepiej ze sobą współgrały. W chwili obecnej jeżeli puzelki przesłonią nam figurę, to nie mamy jak tej ruszyć. Mam plan, jak rozwiązać to na płaszczyźnie UX - zobaczymy, jak to wyjdzie. Drugorzędnym celem jest wprowadzenie punktu startowego i punktu końcowego - bo powoli wezmę się za rozwój mechanizmu gry. Na sam koniec zostawiam testy, które coraz bardziej dopominają się o swoje miejsce projekcie.

Zatem, w punktach:

  1. Warstwy w edytorze
  2. Określenie i modyfikacja punktu startowego i końcowego
  3. 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.