Gra 2D, część 13: Edytor poziomów cz. 3

02.06.2011 - Marcin Milewski
TrudnośćTrudność

Implementacja PlatformEditorCommand: rysowanie wielu pól

Na koniec zajmiemy się implementacją najciekawszego polecenia w naszym edytorze, to znaczy tego, które wspólnie z multipędzlem było motywacją do powstania tego artykułu.

W konstruktorze przekazujemy początkowy oraz końcowy punkt zaznaczenia. Są to dwa dowolne punkty we współrzędnych świata, które wyznaczają prostokątny obszar. Poza tym w klasie przechowujemy pola, które wcześniej były na obszarze, który wypełnimy podczas wywołania metody Execute. Oto definicja klasy PlatformEditorCommand:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Plik: editor/EditorCommand.h
class PlatformEditorCommand;
typedef boost::shared_ptr<PlatformEditorCommand> PlatformEditorCommandPtr;
 
class PlatformEditorCommand : public EditorCommand {
public:
    explicit PlatformEditorCommand(const Position& start, const Position& end)
      : m_is_ready(false),
        m_beg(start), m_end(end) {
    }
 
    virtual void Execute(Editor* editor);
    virtual void Undo(Editor* editor);
    virtual bool IsReady() const;
 
private:
    bool m_is_ready;         // Czy polecenie jest gotowe do wykonania
    Position m_beg, m_end;   // Początek i koniec zaznaczenia. Wsp.świata
    std::vector<FT::FieldType> m_saved_fields;  // zapisane pola planszy
};
  

Przejdźmy do implementacji. Metoda Execute działa w trzech fazach:

  1. Przygotowanie punktów (m.in. sortowanie) i innych potrzebnych wartości (np. szerokość zaznaczenia);
  2. Zapisanie aktualnego stanu planszy. Dzięki temu będzie możliwość wycofania polecenia;
  3. Namalowanie nowych kafelków.
Możemy jeszcze wyróżnić fazę zero, czyli sprawdzenie warunków początkowych. W naszym przypadku chodzi o sprawdzenie wyniku funkcji IsReady. Jeżeli polecenie z jakiegoś powodu nie jest gotowe do wykonania, to nie powinniśmy tego robić. W innym razie możemy spodziewać się niezdefiniowanego zachowania.

Zerowa oraz pierwsza faza są bardzo proste. Kod, który widzimy poniżej, pojawił się już podczas rysowania szkicu multipędzla. Ponownie wykorzystujemy klasę TileGridHelper:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Plik: editor/PlatformEditorCommand.cpp
#include "Editor.h"
#include "EditorCommand.h"
#include "../TileGridHelper.h"
 
void PlatformEditorCommand::Execute(Editor* editor) {
    if (IsReady() == false) {
        throw std::logic_error("PlatformEditorCommand: Nie można uruchomić "
            "polecenia, które nie jest gotowe.");
    }
    TileGridHelper tgh(m_beg, m_end);
    tgh.SnapToGrid().SortCoordsOfBox();
    const unsigned tiles_hor = tgh.TilesHorizontally();
    const unsigned tiles_ver = tgh.TilesVertically();
  

Druga faza, to zapamiętanie aktualnego stanu planszy, dzięki czemu będzie możliwość wycofania wykonanej komendy. Pola zapisujemy w jednowymiarowym wektorze. Zapisywanie w dwuwymiarowej siatce nie jest potrzebne, gdyż znamy szerokość prostokąta do wypełnienia, więc możemy łatwo wyliczyć, kiedy zaczyna się nowy wiersz.

1
2
3
4
5
6
7
8
9
// Plik: editor/PlatformEditorCommand.cpp, kontynuacja kody powyżej
    // Zapisz kafelki, które aktualnie występują na planszy
    for (unsigned ver = 0; ver < tiles_ver; ver++) { // wypełnienie
        for (unsigned hor = 0; hor < tiles_hor; hor++) {
            m_saved_fields.push_back(
                editor->GetFieldAt(tgh.Beg() + Position(hor, ver)));
        }
    }
  

Trzecia faza wykonywania polecenia polega na wypełnieniu zaznaczonego prostokąta odpowiednimi polami. Poniższy kod najpierw wypełnia całość środkowym kaflem (PlatformMid), a następnie nadpisuje krawędzie. Lewa krawędź jest wypełniana przez kafel PlatformLeft, prawa przez PlatformRight, a górna -- PlatformTop. W ostatnim kroku umieszczamy odpowiednie kafle w górnych narożnikach.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Plik: editor/PlatformEditorCommand.cpp, kontynuacja kody powyżej
    // Prostokąt
    for (unsigned ver = 0; ver < tiles_ver; ver++) { // wypełnienie
        for (unsigned hor = 0; hor < tiles_hor; hor++) {
            editor->SetFieldAt(
                tgh.Beg() + Position(hor, ver), FT::PlatformMid);
        }
    }
    for (unsigned hor = 0; hor < tiles_hor; hor++) { // górna krawędź
        editor->SetFieldAt(
            tgh.Beg() + Position(hor, tiles_ver - 1), FT::PlatformTop);
    }
    for (unsigned ver = 0; ver < tiles_ver; ver++) { // boki
        editor->SetFieldAt(
            tgh.Beg() + Position(0, ver), FT::PlatformLeft);
        editor->SetFieldAt(
            tgh.Beg() + Position(tiles_hor - 1, ver), FT::PlatformRight);
    }
    // narożniki (górny lewy i górny prawy)
    editor->SetFieldAt(
        tgh.Beg() + Position(0, tiles_ver - 1), FT::PlatformTopLeft);
    editor->SetFieldAt(
        tgh.Beg() + Position(tiles_hor - 1, tiles_ver - 1), FT::PlatformTopRight);
}
  
Przykładowe platformy, które można utworzyć multipędzlem.

Przejdźmy teraz do wycofywania ostatnio wykonanego polecenia. Na początku sprawdzamy czy było ono gotowe do wykonania. Jeżeli nie, to próba wycofania go prawdopodobnie oznacza błąd w aplikacji (gdyż nie powinno się ono znaleźć na liście wykonanych poleceń), więc chcemy to zasygnalizować. Następnie (po raz trzeci) wykorzystujemy TileGridHelper do uporządkowania punktów oraz uzyskania informacji o rozmiarach prostokątnego zaznaczenia.
Po upewnieniu się, że zapamiętana liczba pól zgadza się z wymiarami prostokąta, przystępujemy do odtwarzania dawnej zawartości. Cała trudność tego zadania polega na dobrym skonstruowaniu dwóch zagnieżdżonych pętli (choć można uzyskać równoważny efekt posługując się tylko jedną pętlą - jak?). Kod całej funkcji Undo znajduje się poniżej:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void PlatformEditorCommand::Undo(Editor* editor) {
    if (IsNotReady()) {
        throw std::logic_error("PlatformEditorCommand: Nie można wycofać "
                "polecenia, które nie jest gotowe.");
    }
    if (m_saved_fields.empty()) {
        return;
    }
    TileGridHelper tgh(m_beg, m_end);
    tgh.SnapToGrid().SortCoordsOfBox();
    unsigned tiles_hor = tgh.TilesHorizontally();
    unsigned tiles_ver = tgh.TilesVertically();
    assert(tiles_hor * tiles_ver == m_saved_fields.size() && "Zła liczba pól");
    std::vector<FT::FieldType>::const_iterator it = m_saved_fields.begin();
    for (unsigned ver = 0; ver < tiles_ver; ver++) { // wypełnienie
        for (unsigned hor = 0; hor < tiles_hor; hor++) {
            editor->SetFieldAt(tgh.Beg() + Position(hor, ver), *it);
            ++it;
        }
    }
}
  

Ostatnią funkcją, której dostarcza każde polecenie w edytorze jest IsReady. W przypadku PlatformEditorCommand jest to kilka warunków, które doprowadziłyby do powstania nieciekawego wypełnienia. Poniżej znajduje się cały kod metody PlatformEditorCommand::IsReady:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool PlatformEditorCommand::IsReady() const {
    if (m_beg.X() < 0 || m_beg.Y() < 0 || m_end.X() < 0 || m_end.Y() < 0) {
        return false;
    }
    TileGridHelper tgh(m_beg, m_end);
    tgh.SnapToGrid().SortCoordsOfBox();
    unsigned tiles_hor = tgh.TilesHorizontally();
    unsigned tiles_ver = tgh.TilesVertically();
    if (tiles_hor == 1) return false;   // szerokość == 1
    if (tiles_hor < 1 || tiles_ver < 1) return false;    // mniej niż 1x1
    if (tiles_ver == 1 && tiles_hor == 1) return false;  // dokładnie 1x1
    return true;
}
  

Powyższa zmiana jest ostatnią wprowadzoną w tym artykule. Teraz możemy w pełni cieszyć się nową funkcjonalnością w naszym edytorze. Nasz kod jest gotowy na dodawanie kolejnych poleceń do naszego edytora. W tym celu należy zrobić dwie rzeczy:

  1. Dodać multipędzel dziedziczący po klasie MultiBrush i dostarczyć implementacji wirtualnych metod DrawSketch oraz GetCommand;
  2. Dodać polecenie (czyli klasę dziedziczącą po EditorCommand), które wprowadzi modyfikacje do poziomu.

Dzięki zastosowaniu wzorca projektowego polecenie, każda klasa odpowiadająca zadaniu ma bardzo dobrze określony zakres swoich obowiązków. Dzięki temu jej kod jest krótki, łatwy do napisania i z dużym prawdopodobieństwem nie zawiera poważnych błędów. Składając większy kawałek kodu z małych, dobrze zdefiniowanych cegiełek nie tylko zwiększamy przejrzystość naszego projektu, ale także dajemy możliwość poskładania klocków w inny sposób. Takich zalet na pewno nie mają długie na kilka ekranów funkcje (por. ang. god method) czy wszystkorobiące klasy (tzw. god class lub god object).

Masz pytanie, uwagę? Stworzyłeś poziom, którym chcesz się podzielić? A może zauważyłeś błąd? Powiedz o tym na forum.

Pobierz końcowy kod źródłowy do tego artykułu.

Zadania dla dociekliwych

  1. Dodaj możliwość cofania zmiany początkowego położenia bohatera.
  2. Dodaj możliwość ponownego wykonania wycofanej zmiany (ang. redo).
  3. Napisz polecenie podobne do PlatformEditorCommand, ale wyświetlające schody. Pozwoli to na szybkie dodawanie przeszkody do gry.
  4. Aktualna implementacja wycofywania poleceń powoduje natychmiastowe zniknięcie efektów działania pewnej akcji. Zadanie polega na implementacji spowolnienia tej akcji, np. przez stopniowe zanikanie usuwanych elementów.
  5. (trudniejsze) Akcje w naszym edytorze są reprezentowane przez polecenia przechowywane w odpowiednim kontenerze w klasie Editor. Umiemy cofać wykonane polecenia. Ale skąd gracz ma wiedzieć co zostanie wycofane? Dodaj edytora możliwość poinformowania użytkownika, która komenda zostanie wycofana jako następna. W przypadku dodawania elementów do planszy, efektem działania mogłyby być migające jednostki lub kafle planszy.
0
Twoja ocena: Brak

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com