Nieodłącznym elementem każdego edytora poziomów jest graficzny interfejs, zatem dodamy go także do naszej gry. W artykule zajmiemy się opracowaniem klas do obsługi graficznego interfejsu użytkownika. Dzięki temu edytowanie poziomów będzie łatwe i przyjemne.
Poprzedni artykuł - Edytor poziomów cz. 1 [2] Następny artykuł - Edytor poziomów cz. 3 [3]
Pobierz [4] początkowy kod źródłowy do tego artykułu.
W poprzedniej części [2] cyklu o tworzeniu gry platformowej zajmowaliśmy się implementacją mechaniki edytora poziomów. W tym artykule dodamy do niego graficzny interfejs użytkownika. Użytkownik, przy pomocy myszki, będzie mógł wybierać jakie elementy chce dodawać do poziomu klikając w wybrane miejsce na planszy. Dodamy także możliwość usuwania pól z poziomu.
Od strony wizualnej, widoczny będzie tak naprawdę tylko jeden rodzaj elementu -- przycisk. Zadbamy jednak o to, aby można było łatwo dodawać kolejne kontrolki (zwane także widżetami, z ang. widget). Do reprezentacji przycisków wykorzystamy animowane sprite'y obiektów, które się pod nimi kryją.
Poniżej możemy zobaczyć film prezentujący naszą grę wzbogaconą o graficzny interfejs użytkownika w edytorze.
Jak dotąd nie potrzebowaliśmy mechanizmu GUI (Graphical User Interface). Aby się o tym przekonać, wystarczy sięgnąć pamięcią do poprzednich części cyklu tworzenia gry w stylu Mario. Wydaje się jednak, że z czasem przybędzie elementów do wyświetlania, zatem warto wydzielić klasy odpowiedzialne za obsługę poszczególnych elementów graficznego interfejsu użytkownika.
Abstrakcyjna klasa bazowa Gui będzie wykonywała zadanie analogiczne do AppState, dlatego znajdziemy tutaj takie metody czysto wirtualne, jak Init, Draw czy Update. Ponadto udostępnimy kilka publicznych (ale także wirtualnych) funkcji do obsługi urządzeń wejścia -- klawiatury oraz myszy. Deklarację (i jednocześnie definicję) klasy Gui umieszczamy w podkatalogu gui/ w pliku Gui.h:
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 26 | // Plik: gui/Gui.h #ifndef __GUI_H_INCLUDED__ #define __GUI_H_INCLUDED__ class Gui; typedef boost::shared_ptr<Gui> GuiPtr; class Gui : public boost::enable_shared_from_this<Gui> { public: virtual ~Gui() {} virtual void Start() = 0; virtual void Init() = 0; virtual void Draw() = 0; virtual void Update(double dt) = 0; virtual bool OnKeyDown(const SDLKey& /* key */) { return false; } virtual bool OnKeyUp(const SDLKey& /* key */) { return false; } virtual bool OnMouseMove(double /* x */, double /* y */) { return false; } virtual bool OnMouseDown(Uint8 /* button */, double /* x */, double /* y */) { return false; } }; #endif |
Podstawową jednostką wyświetlaną w GUI będzie widżet. Będziemy reprezentować go klasą GuiWidget, która dostarcza podstawowych metod do rysowania, aktualizacji, manipulacji pozycją oraz rozmiarem kontrolki na ekranie. Metoda GetPosition zwraca położenie lewego dolnego narożnika widżetu, a GetSizedPosition -- prawego górnego. Możliwa jest także manipulacja widocznością kontrolki przez wykorzystanie metody SetVisible. Oto pełny kod klasy GuiWidget:
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | // Plik: gui/GuiWidget.h #ifndef __GUIWIDGET_H_INCLUDED__ #define __GUIWIDGET_H_INCLUDED__ #include "../BasicMathTypes.h" class GuiWidget; typedef boost::shared_ptr<GuiWidget> GuiWidgetPtr; class GuiWidget { public: explicit GuiWidget(Position position, Size size, bool is_visible) : m_position(position), m_size(size), m_is_visible(is_visible) { } virtual ~GuiWidget() {} virtual void Draw() const = 0; virtual void Update(double dt) = 0; Position GetPosition() const { return m_position; } Position GetSizedPosition() const { return Position(GetPosition().X() + GetSize().X(), GetPosition().Y() + GetSize().Y()); } Size GetSize() const { return m_size; } bool IsVisible() const { return m_is_visible; } GuiWidget* MoveBy(const Vector2& vector) { m_position += vector; return this; } GuiWidget* SetSize(const Vector2& vector) { m_size = vector; return this; } GuiWidget* SetPosition(const Vector2& vector) { m_position = vector; return this; } GuiWidget* SetVisible(bool is_visible) { m_is_visible = is_visible; return this; } Aabb GetAabb() const { return Aabb(GetPosition().X(), GetPosition().Y(), GetSizedPosition().X(), GetSizedPosition().Y()); } private: Size m_size; // rozmiar (szerokość i wysokość) (przestrzeń okna) Position m_position; // położenia (lewego dolnego narożnika) na ekranie bool m_is_visible; // czy kontrolka jest widoczna }; #endif |
GuiWidget jest jedynie klasą bazową dla wszystkich kontrolek. Poniżej przedstawiamy kod bardzo prostego elementu graficznego interfejsu użytkownika -- przycisk wyświetlany w postaci sprite'a:
To proste opakowanie sprite'a jest klasą wyjściową dla przycisku przedstawiającego konkretne pędzle. W konstruktorze podajemy sprite, który będzie reprezentacją pędzla na ekranie, położenie oraz rozmiar przycisku, a także wskaźnik na przechowywany pędzel. Całość zajmuje jedynie kilka wierszy kodu. Ponieważ jest to element GUI specyficzny dla edytora, dlatego plik BrushButton.h umieszczamy w katalogu editor.
Ostatnim elementem, który należy omówić, jest klasa EditorGui. Reprezentuje ona konkretne GUI, więc dziedziczy po klasie Gui. Jej zadaniem jest przechowywanie kontrolek oraz reagowanie na komunikaty od użytkownika wydawane za pomocą myszy -- zmiana położenia oraz naciśnięcie lewego przycisku. Ta klasa wie także, który pędzel jest aktywny, czyli który przycisk na interfejsie został kliknięty jako ostatni. Poniżej znajduje się deklaracja klasy EditorGui:
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 26 27 28 29 30 31 32 33 | #ifndef __EDITORGUI_H_INCLUDED__ #define __EDITORGUI_H_INCLUDED__ #include "../gui/Gui.h" #include "BrushButton.h" class EditorGui; typedef boost::shared_ptr<EditorGui> EditorGuiPtr; class EditorGui : public Gui, public boost::enable_shared_from_this<EditorGui> { public: explicit EditorGui(); void Start(); void Init(); void Draw(); void Update(double dt); bool OnMouseMove(double x, double y); bool OnMouseDown(Uint8 button, double x, double y); BrushPtr GetActiveBrush() const { return m_active_brush; } private: typedef std::vector<BrushButtonPtr> BrushButtonContrainer; BrushButtonContrainer m_buttons; BrushButtonPtr m_hovered_button; BrushPtr m_active_brush; }; #endif |
Definicja tej klasy zaczyna się od dodania odpowiednich kontrolek do edytora. Konkretnym miejscem, w którym będą przechowywane jest prywatny wektor m_buttons. Dla skrócenia zapisu, do tworzenia kontrolek wykorzystujemy makra. Jeżeli ktoś jest ich przeciwnikiem, to może użyć w tym miejscu funkcji. Nie ma to dużego znaczenia, a w razie potrzeby kod można zawsze szybko przystosować do nowych warunków. Oto ciała pierwszych funkcji:
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | // Plik: editor/EditorGui.cpp #include "../StdAfx.h" #include "../Engine.h" #include "EditorGui.h" EditorGui::EditorGui() { } void EditorGui::Start() { } void EditorGui::Init() { m_buttons.clear(); const Size default_size = Size(.1, .1); #define BUTTON(name,pos,size,type) \ BrushButtonPtr(new BrushButton( Sprite::GetByName(name), pos, size, \ Brush::New(Sprite::GetByName(name), type))) #define ADD_BUTTON(name,pos,size,type) \ m_buttons.push_back(BUTTON(name,pos,size,type)) // nazwy sprite'ów zdefiniowane są w SpriteConfig::SpriteConfig ADD_BUTTON("gui_eraser", Position( 0, .0), default_size*2, Brush::ST::Eraser); // rozwinięcie powyższego makra (ponad 2 razy dłuższe): // m_buttons.push_back(BrushButtonPtr(new BrushButton( // Sprite::GetByName("gui_eraser"), Position( 0, .0), default_size*2, // Brush::New(Sprite::GetByName("gui_eraser"), Brush::ST::Eraser)))); ADD_BUTTON("player_stop", Position(.1, .8), default_size, Brush::ST::Player); ADD_BUTTON("EndOfLevel", Position(.2, .8), default_size, FT::EndOfLevel); ADD_BUTTON("twinshot_upgrade", Position(.4, .8), default_size, ET::TwinShot); ADD_BUTTON("mush_stop", Position(.8, .8), default_size, ET::Mush); ADD_BUTTON("PlatformTopLeft", Position(.3, .5), default_size, FT::PlatformTopLeft); ADD_BUTTON("PlatformTop", Position(.4, .5), default_size, FT::PlatformTop); ADD_BUTTON("PlatformTopRight", Position(.5, .5), default_size, FT::PlatformTopRight); ADD_BUTTON("PlatformLeft", Position(.3, .4), default_size, FT::PlatformLeft); ADD_BUTTON("PlatformMid", Position(.4, .4), default_size, FT::PlatformMid); ADD_BUTTON("PlatformRight", Position(.5, .4), default_size, FT::PlatformRight); ADD_BUTTON("PlatformLeftRight", Position(.4, .15), default_size, FT::PlatformLeftRight); ADD_BUTTON("PlatformLeftTopRight", Position(.4, .25), default_size, FT::PlatformLeftTopRight); #undef ADD_BUTTON #undef BUTTON } |
Zauważmy, że pojawił się tutaj element gui_eraser. Będzie on odpowiadał rodzajowi pędzla przeznaczonego do usuwania elementów z poziomu. Odpowiadający mu sprite dodajemy do naszego atlasu, a w pliku SpriteConfig.cpp podajemy jego położenie na obrazku:
1 2 3 4 | // Plik: SpriteConfig.cpp; konstruktor klasy SpriteConfig Insert("gui_eraser", SpriteConfigData(DL::Entity, 1, 1, 10*32, 12*32, 32, 32, true, false)); |
Mając zdefiniowane widżety możemy je rysować oraz aktualizować. Całe GUI będzie rysowane na nieco ciemniejszym tle, dlatego metoda EditorGui::Draw zaczyna się od wywołania funkcji DrawQuad klasy Renderer. Następnie rysujemy wszystkie przyciski poza aktywnym (tym pod kursorem myszy), który rysujemy osobno dodając pod nim podświetlenie. Do aktualizacji wykorzystujemy algorytm z biblioteki standardowej -- std::for_each. Oto kod obu metod:
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 26 27 | void EditorGui::Draw() { // ciemne tło Engine::Get().GetRenderer()->DrawQuad(0, 0, 1, 1, 0,0,0,.7); // widoczne kontrolki for (BrushButtonContrainer::const_iterator i=m_buttons.begin(); i!=m_buttons.end(); ++i) { if ((*i)->IsVisible() && (*i)!=m_hovered_button) { (*i)->Draw(); } } // przycisk, nad którym jest kursor, rysujemy osobno. if (m_hovered_button) { m_hovered_button->Draw(); const Aabb& box = m_hovered_button->GetAabb(); Engine::Get().GetRenderer()->DrawQuad(box.GetMinX(), box.GetMinY(), box.GetMaxX(), box.GetMaxY(), 1,1,1, .4); } } void EditorGui::Update(double dt) { std::for_each(m_buttons.begin(), m_buttons.end(), boost::bind(&GuiWidget::Update, _1, dt)); } |
Zostały jeszcze dwie metody -- OnMouseMove oraz OnMouseDown. Pierwsza obsługuje zmianę położenie kursora na ekranie. Ponieważ w naszym GUI jest mało kontrolek, to możemy sobie pozwolić na liniowe przejrzenie wszystkich, aby znaleźć tę, nad którą znajduje się wskaźnik.
Druga z poniższych metod odpowiada za reakcję na naciśnięcie przycisku myszy. Jest ona bardzo prosta, gdyż jedynie sprawdza czy użytkownik wskazuje kursorem na którąś z kontrolek. Jeżeli tak jest, to zmienia aktywny pędzel na właściwy dla wskazywanego widżetu. Poniżej kod obu metod:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | bool EditorGui::OnMouseMove(double x, double y) { m_hovered_button.reset(); const Aabb cursor_aabb = Aabb(x, y, x+.02, y+0.02); for (BrushButtonContrainer::const_iterator i=m_buttons.begin(); i!=m_buttons.end(); ++i) { if ((*i)->IsVisible() && (*i)->GetAabb().Collides(cursor_aabb)) { m_hovered_button = *i; } } return bool(m_hovered_button); } bool EditorGui::OnMouseDown(Uint8 /* button */, double /* x */, double /* y */){ if (!m_hovered_button) { return false; } m_active_brush = m_hovered_button->GetBrush(); return true; } |
Przy okazji definiowania kontrolki BrushButton wykorzystywaliśmy klasę Brush, która była przez nią opakowywana. Do tej pory znaliśmy jednak jedynie zalążek tej klasy. Za chwilę poznamy całą jej implementację.
Zanim jednak przejdziemy do uaktualnienia klasy Editor, wyjaśnijmy, czym jest pędzel. Po raz pierwszy pojawił się w poprzednim artykule, a w tym wykorzystaliśmy go podczas tworzenia specjalnej kontroli. Otóż, pędzel ten ma takie samo zadanie jak w edytorze grafiki. To znaczy, że wybieramy pędzel z podręcznej palety, a następnie nim rysujemy. W naszym edytorze będzie dokładnie tak samo. Dokonamy jednak pewnego uproszczenia i uznamy gumkę za specjalny rodzaj pędzla.
Pędzle w naszym edytorze dzielimy na 3 rodzaje:
Pędzel będzie przechowywał (można o tym myśleć w kategorii opakowania -- pędzel opakowuje sprite'a) instancję klasy Sprite. Obiekt zawierający instancję typu Brush będzie mógł ją odpytać o posiadany obrazek, a następnie wykorzystać go do własnych celów. Zwykle oznacza to, iż sprawdzi on jakiego obiektu reprezentacją jest sprite, a następnie doda opis odpowiedniego elementu do poziomu, który potem zostanie zapisany do pliku lvl lub ents. Zauważ, że ten mechanizm jest bardziej ogólny niż przedstawione tu zastosowanie.
Poniżej znajduje się implementacja omawianej klasy. Dla skrócenia kodu wszystkie trzy typy pędzla są reprezentowane przez tę samą klasę. W przypadku rozwoju gry o kolejne rodzaje pędzli, jest to dobre miejsce, żeby poćwiczyć refaktoryzację kodu. Oto kod klasy Brush:
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | // Plik: editor/Brush.h #ifndef __BRUSH_H_INCLUDED__ #define __BRUSH_H_INCLUDED__ class Brush; typedef boost::shared_ptr<Brush> BrushPtr; class Brush { public: struct ST { enum SpecialType { UNKNOWN, Player, Eraser }; }; explicit Brush(SpritePtr sprite, FT::FieldType ft) : m_sprite(sprite), m_is_field(true), m_is_entity(false), m_is_special(false), m_field_type(ft) { } explicit Brush(SpritePtr sprite, ET::EntityType et) : m_sprite(sprite), m_is_field(false), m_is_entity(true), m_is_special(false), m_entity_type(et) { } explicit Brush(SpritePtr sprite, ST::SpecialType st) : m_sprite(sprite), m_is_field(false), m_is_entity(false), m_is_special(true), m_special_type(st) { } bool IsField() const { return m_is_field; } bool IsEntity() const { return m_is_entity; } bool IsSpecial() const { return m_is_special; } // Zwraca typ pola/encji/specjalny. // Wartość jest poprawna, gdy IsField/IsEntity/IsSpecial()==true FT::FieldType GetFieldType() const { return m_field_type; } ET::EntityType GetEntityType() const { return m_entity_type; } ST::SpecialType GetSpecialType() const { return m_special_type; } SpritePtr GetSprite() const { return m_sprite; } private: SpritePtr m_sprite; bool m_is_field; bool m_is_entity; bool m_is_special; FT::FieldType m_field_type; ET::EntityType m_entity_type; ST::SpecialType m_special_type; }; #endif |
W celu ułatwienia tworzenia instancji tej klasy, dodamy metody statyczne tworzące każdy z typów pędzla. Nazywamy je New, sugerując tym samym, że zwrócony zostanie wskaźnik (w naszym przypadku inteligentny wskaźnik, bo takimi się posługujemy), a nie obiekt klasy Brush. Poniższy kod powinien znaleźć się w sekcji publicznej tej klasy.
1 2 3 4 5 6 7 8 9 10 11 12 13 | // Plik: editor/Brush.h static BrushPtr New(SpritePtr sprite, FT::FieldType ft) { return BrushPtr(new Brush(sprite, ft)); } static BrushPtr New(SpritePtr sprite, ET::EntityType et) { return BrushPtr(new Brush(sprite, et)); } static BrushPtr New(SpritePtr sprite, ST::SpecialType st) { return BrushPtr(new Brush(sprite, st)); } |
Korzystanie z takich statycznych metod jest bardzo wygodne, jeżeli często tworzymy wskaźniki na obiekty. Bez nich, pisalibyśmy:
1 2 | BrushPtr(new Brush(sprite, type)); |
Dzięki nim możemy napisać krótszy i lepiej oddającą intencje programisty następujący kod:
1 2 | Brush::New(sprite, type); |
Utworzyliśmy już kompletny mechanizm obsługujący graficzny interfejs użytkownika. W naszym edytorze użytkownik może w prosty sposób wybrać, gdzie i jaki element (jednostka, bonus, przeciwnik, pole planszy,...) chce dodać do poziomu. Umieszczenie nowego obiektu odbywa się poprzez kliknięcie myszką. Aby jednak odniosło swój skutek, sterowanie w aplikacji musi dotrzeć w odpowiednie miejsce kodu obsługującego GUI. Dlatego też swoje kroki kierujemy teraz w stronę integracji systemu GUI z dotychczas rozwiniętym edytorem.
Pierwszym polem jakie należy dodać jest wskaźnik (oczywiście inteligentny) na klasę bazową Gui. To właśnie do niej klasa Editor będzie przekazywała m.in. komunikaty z urządzeń wejścia. Będzie to możliwe jedynie wtedy gdy GUI będzie prezentowane na ekranie. Aby obsłużyć pokazywanie i ukrywanie graficznego interfejsu użytkownika dodajemy pole (nazywane czasem flagą lub znacznikiem ze względu na swój charakter, czyli możliwość przyjęcia tylko jednej z dwóch wartości) m_is_gui_visible. Konstruktor klasy Editor prezentuje się teraz następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | // Należy także pamiętać o dołączeniu nagłówka EditorGui.h explicit Editor(LevelPtr level) : m_next_app_state(), m_in_game(false), m_gui(new EditorGui), // NOWE! m_is_gui_visible(true), // NOWE! m_level(level), m_viewer_offset_x(0), // Update zadba o poprawną wartość m_pointer_x(0), m_pointer_y(0), m_pointer_window_x(0), m_pointer_window_y(0), m_keys_down(SDLK_LAST, false) // Wszystkie klawisze puszczone { // nop SetDone(false); } |
Nowe (prywatne) pola tej klasy:
1 2 3 | EditorGuiPtr m_gui; // kontrolki do wybierania stawianych pól bool m_is_gui_visible; // czy kontrolki są widoczne? |
Publiczne metody do włączania/wyłączania oraz odpytywania GUI o widoczność:
1 2 3 4 5 | // pokazuje/ukrywa gui void ToggleGui() { m_is_gui_visible = !m_is_gui_visible; } bool IsGuiVisible() const { return m_is_gui_visible; } bool IsGuiHidden() const { return !IsGuiVisible(); } |
Jak już wspominaliśmy przy okazji prezentacji pędzla, może być on jednego z trzech rodzajów, co związane jest z akcją zleconą przez użytkownika:
Poniżej znajduje się kod usprawniający sprawdzanie, czy wskazany tryb jest aktualnie aktywny. Są to metody publiczne.
1 2 3 4 5 6 7 8 | // w jakim trybie jest rysowany pędzel bool InPaintingFieldMode() const { return m_brush && m_brush->IsField(); } bool InPaintingEntityMode() const { return m_brush && m_brush->IsEntity(); } bool InPaintingSpecialMode() const { return m_brush && m_brush->IsSpecial();} BrushPtr GetBrush() const { return m_brush; } Editor* SetBrush(BrushPtr brush) { m_brush = brush; return this; } |
Ostatnią metodą, która będzie przydatna podczas implementacji nowych funkcjonalności jest ShouldSnapToGrid. Będzie ona odpowiadać "tak", gdy dodawany element powinien zostać wyrównany do (niewidzialnej) siatki, oraz "nie" w przeciwnym razie. Pierwszą wartość będzie zwracała przede wszystkim dla pól planszy. Będzie to jednak właściwe miejsce na dodanie kolejnych "wyjątków", na przykład jeżeli zażyczymy sobie, aby trzymanie klawisza "CTRL" wymuszało takie zachowanie edytora. Poniżej znajduje się prosta deklaracje omówionej metody:
1 2 3 | // czy rysowany obiekt (pod pędzlem) powinien być przyciągane do siatki bool ShouldSnapToGrid() const; |
Przed chwilą przedstawiliśmy aktualizacje w deklaracji klasy Editor, dlatego teraz zajmiemy się jej definicją. Pierwsze wiersze kodu, które należy dopisać, to wywoływanie metod typu Init, Update, Draw,... w odpowiednich miejscach. Funkcję Start obiektu m_gui wywołujemy w metodzie Editor::Start, Init -- na końcu Editor::Init, a Update -- przed powrotem z funkcji Editor::Update. Poniżej poszczególne fragmenty kodu:
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 26 | void Editor::Start() { m_gui->Start(); } void Editor::Init() { // dodawanie sprite'ów // (...) m_gui->Init(); } bool Editor::Update(double dt) { // (...) // upewnij się że edytor nie zagląda poza lewą krawędź planszy. // Znajdują się tam ujemne wartości na osi odciętych, co może powodować // błędy w obliczeniach. const double tiles_in_row = 1.0/Engine::Get().GetRenderer()->GetTileWidth(); m_viewer_offset_x = std::max(m_viewer_offset_x, tiles_in_row/2-1); // aktualizacja GUI m_gui->Update(dt); // NOWE! return !IsDone(); } |
Miejsce rysowania GUI przygotowaliśmy w metodzie Editor::DrawBrushAndGui, więc teraz wystarczy podmienić odpowiedni TODO na wywołanie m_gui->Draw(). Rysowanie jest jednak troszkę bardziej skomplikowane. Trzeba zadbać jeszcze o przypisanie aktywnego pędzla do pola m_brush oraz obsłużenie metody ShouldSnapToGrid. Poprzednią wersję Editor::DrawBrushAndGui, czyli:
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 26 27 28 29 30 31 32 33 | void Editor::DrawBrushAndGui(double viewer_x) { const double tile_width = Engine::Get().GetRenderer()->GetTileWidth(); const double tile_height = Engine::Get().GetRenderer()->GetTileHeight(); glPushAttrib(GL_ENABLE_BIT); { // wyłącz test głębokości, żeby pędzel oraz GUI były zawsze na wierzchu glDisable(GL_DEPTH_TEST); BrushPtr default_brush = Brush::New(Sprite::GetByName("PlatformTop"), FT::PlatformTop); SetBrush(default_brush); if (GetBrush()) { glPushMatrix(); { glTranslated(viewer_x, 0, 0); Size size(tile_width, tile_height); Position position(m_pointer_x * tile_width, m_pointer_y * tile_height); // podświetlenie Engine::Get().GetRenderer()->DrawQuad(position, position+size, 1,1,1,.4); // obiekt spod pędzla GetBrush()->GetSprite()->DrawCurrentFrame(position, size) } glPopMatrix(); } // TODO: tutaj narysować GUI } glPopAttrib(); } |
Zamieniamy na:
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 26 27 28 29 30 31 32 33 34 35 36 37 38 | void Editor::DrawBrushAndGui(double viewer_x) { const double tile_width = Engine::Get().GetRenderer()->GetTileWidth(); const double tile_height = Engine::Get().GetRenderer()->GetTileHeight(); glPushAttrib(GL_ENABLE_BIT); { // wyłącz test głębokości, żeby pędzel oraz gui były zawsze na wierzchu glDisable(GL_DEPTH_TEST); SetBrush(m_gui->GetActiveBrush()); if (GetBrush()) { glPushMatrix(); { glTranslated(viewer_x, 0, 0); Size size(tile_width, tile_height); Position position(m_pointer_x * tile_width, m_pointer_y * tile_height); // niektóre pola są wyrównywane do siatki, // więc trzeba przyciąć współrzędne if (ShouldSnapToGrid()) { position = Position( static_cast<int>(m_pointer_x) * tile_width, static_cast<int>(m_pointer_y) * tile_height); } // podświetlenie Engine::Get().GetRenderer()->DrawQuad( position, position+size, 1,1,1,.4); // obiekt spod pędzla GetBrush()->GetSprite()->DrawCurrentFrame(position, size); } glPopMatrix(); } if (IsGuiVisible()) { // TU PODMIENIAMY m_gui->Draw(); } } glPopAttrib(); } |
I znów ta metoda ShouldSnapToGrid.... Może więc już pora, aby przedstawić jej implementację? Zwraca prawdę w dwóch przypadkach: gdy rysujemy podłoże oraz w kiedy używamy gumki. W przyszłości można dodać tutaj więcej warunków. Oto kod:
1 2 3 4 5 6 7 8 9 10 | bool Editor::ShouldSnapToGrid() const { if (InPaintingFieldMode()) return true; if (InPaintingSpecialMode() && GetBrush()->IsSpecial() && GetBrush()->GetSpecialType()==Brush::ST::Eraser) return true; return false; } |
Zajrzyjmy do ostatniej metody pomocniczej -- ActionAtCoords. Na podstawie argumentów, czyli współrzędnych kursora (we współrzędnych świata, stąd typ double), oraz wybranego przez użytkownika pędzla, decyduje ona o akcji do wykonania. Wiele przypadków jest łatwych w implementacji. Ciekawe jest dodawanie nowej encji. Zauważmy, że jednostka tworzona jest pod dwiema postaciami. Pierwsza jest jedynie opisem, na podstawie którego jednostka będzie zapisana w pliku *.ents. Opis ten jest zapamiętywany w polu m_entities_to_create. Druga reprezentacja to normalna jednostka w grze (zauważmy wykorzystanie fabryki), co skutkuje dodaniem jej do kontenera m_entities. Oto kompletna implementacje omawianej metody:
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 26 27 28 29 30 31 32 33 34 35 | void Editor::ActionAtCoords(double x, double y) { BrushPtr b = m_gui->GetActiveBrush(); if (b) { if (InPaintingFieldMode()) { SetFieldAt(static_cast<size_t>(x), static_cast<size_t>(y), b->GetFieldType()); } else if (InPaintingEntityMode()) { const ET::EntityType entity_type = b->GetEntityType(); assert(entity_type!=ET::UNKNOWN); assert(entity_type!=ET::COUNT); const std::string name = EntityTypeAsString(entity_type); const LevelEntityData entity_data(name, x, y); m_entities_to_create.push_back(entity_data); EntityFactory factory; m_entities.push_back(factory.CreateEntity(entity_data)); // std::cout << "New entity: " << name << " " // << x << " " << y << std::endl; } else if (InPaintingSpecialMode()) { const Brush::ST::SpecialType special_type = b->GetSpecialType(); if (special_type == Brush::ST::Player) { m_player_data = LevelEntityData("player", x, y); } else if (special_type == Brush::ST::Eraser) { ClearFieldAt(static_cast<size_t>(x), static_cast<size_t>(y)); } else { std::cerr << "Niezdefiniowana akcja w trybie specjalnym" << std::endl; } } else { std::cerr << "Nie odnaleziono trybu rysowania" << std::endl; assert(false && "Nie odnaleziono trybu rysowania"); } } } |
1 2 3 4 5 | #include <typeinfo> class Gracz { /*...*/ }; /*...*/ Gracz bohater; std::cout << typeid(bohater).name(); |
Niestety, w C++ nie da się elementu wyliczenia (ang. enum) w postaci ciągu znaków (a tworzenie osobnej klasy dla każego typu jednostki jest bardzo niepraktyczne). Taka operacja jest nam potrzebna, aby zapisać do pliku rodzaj jednostki. Dlatego musimy dodać prostą funkcję tłumaczącą typ jednostki na instancję klasy std::string.
Na pierwszy rzut oka, takie rozwiązanie powoduje, że po dodaniu nowego typu jednostki musimy dodać odpowiedni wpis do EntityTypeAsString. Faktycznie jest to pewien kłopot. Jednak, gdy ktoś spróbuje dodać taką jednostkę do planszy (za pośrednictwem edytora), to program przerwie pracę z komunikatem, że taki typ jednostki jest nieobsługiwany.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // Plik: Types.h std::string EntityTypeAsString(ET::EntityType et); // Plik: Types.cpp #include "StdAfx.h" #include "Types.h" std::string EntityTypeAsString(ET::EntityType et) { switch (et) { case ET::PlayerBullet : return "player_bullet"; case ET::TwinShot : return "twinshot_upgrade"; case ET::Mush : return "mush"; case ET::UNKNOWN : return "unknown"; case ET::COUNT : assert(false && "count nie jest typem jednostki"); break; } assert(false && "nieobsłużony typ jednostki"); } |
Ostatnia aktualizacja kodu klasy Editor to dostosowanie metody Editor::ProcessEvents do nowych warunków, czyli pojawienia się GUI. Jeżeli jest ono aktywne (widoczne), to należy przekazywać od niego komunikaty wejścia (zdarzenia klawiatury, myszy,...). Jeżeli zostaną tam obsłużone, czyli np. wywołaniem_gui->OnKeyDown zwróci True, to metoda Editor::ProcessEvents już nie powinna przetwarzać tego komunikatu. Poprawienie implementacji jest więc kwestią dopisania kilku warunków. Porównajmy starą wersję:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | void Editor::ProcessEvents(const SDL_Event& event) { // (...) double window_width = Engine::Get().GetWindow()->GetWidth(); double window_height = Engine::Get().GetWindow()->GetHeight(); if (event.type == SDL_QUIT) { SetDone(); } else if (event.type == SDL_KEYDOWN) { m_keys_down[event.key.keysym.sym] = true; } else if (event.type == SDL_KEYUP) { m_keys_down[event.key.keysym.sym] = false; } else if (event.type == SDL_MOUSEMOTION) { m_pointer_window_x = event.motion.x / window_width; m_pointer_window_y = 1.0 - event.motion.y / window_height; m_pointer_x = MapWindowCoordToWorldX(m_pointer_window_x); m_pointer_y = MapWindowCoordToWorldY(m_pointer_window_y); } else if (event.type == SDL_MOUSEBUTTONDOWN) { m_pointer_window_x = event.motion.x / window_width; m_pointer_window_y = 1.0 - event.motion.y / window_height; m_pointer_x = MapWindowCoordToWorldX(m_pointer_window_x); m_pointer_y = MapWindowCoordToWorldY(m_pointer_window_y); ActionAtCoords(m_pointer_x, m_pointer_y); } |
Z nową:
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | void Editor::ProcessEvents(const SDL_Event& event) { // (...) double window_width = Engine::Get().GetWindow()->GetWidth(); double window_height = Engine::Get().GetWindow()->GetHeight(); if (event.type == SDL_QUIT) { SetDone(); } else if (event.type == SDL_KEYDOWN) { if (IsGuiHidden() || m_gui->OnKeyDown(event.key.keysym.sym)==false) { m_keys_down[event.key.keysym.sym] = true; } } else if (event.type == SDL_KEYUP) { if (event.key.keysym.sym==SDLK_1) { ToggleGui(); }else if (IsGuiHidden() || m_gui->OnKeyUp(event.key.keysym.sym)==false){ m_keys_down[event.key.keysym.sym] = false; } } else if (event.type == SDL_MOUSEMOTION) { m_pointer_window_x = event.motion.x / window_width; m_pointer_window_y = 1.0 - event.motion.y / window_height; if (IsGuiVisible() && m_gui->OnMouseMove(m_pointer_window_x, m_pointer_window_y)) { // przesuń kursor poza ekran (można to zrobić ładniej) m_pointer_x = m_pointer_y = 1000; } else { m_pointer_x = MapWindowCoordToWorldX(m_pointer_window_x); m_pointer_y = MapWindowCoordToWorldY(m_pointer_window_y); } } else if (event.type == SDL_MOUSEBUTTONDOWN) { m_pointer_window_x = event.motion.x / window_width; m_pointer_window_y = 1.0 - event.motion.y / window_height; if (IsGuiVisible() && m_gui->OnMouseDown(event.button.button, m_pointer_window_x, m_pointer_window_y)) { } else { m_pointer_x = MapWindowCoordToWorldX(m_pointer_window_x); m_pointer_y = MapWindowCoordToWorldY(m_pointer_window_y); ActionAtCoords(m_pointer_x, m_pointer_y); } } |
Format BMP ma bardzo prostą konstrukcję, ale ponieważ nie jest skompresowany, to zajmuje dużo miejsca. Pojawia się więc pytanie, ile można zyskać? Okazuje się, że całkiem sporo. Nasz nieskompresowany plik z atlasem sprite'ów (tex.bmp) zajmuje 4 MB, natomiast po kompresji (tex.png) tylko 0.18 MB. Ponieważ przerobienie kodu tak, aby obsługiwał nowy format atlasu, jest bardzo proste, warto poświęcić na to chwilę czasu. Pierwszą rzeczą, jaką należy zmienić, jest sposób ładowania obrazka z pliku. Zamieniamy
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | // Plik: Renderer.cpp (...) void Renderer::LoadTexture(const std::string & filename) { std::cout << "Ładowanie obrazka z pliku " + filename + "\n"; // załaduj z pliku SDL_Surface* surface = SDL_LoadBMP(filename.c_str()); if (!surface) { (...) GLenum format; switch (surface->format->BytesPerPixel) { case 3: format = GL_BGR; // w BMP skrajne składowe są zamienione break; case 4: format = GL_BGRA; break; default: std::cerr << "Nieznany format pliku " + filename + "\n"; exit(1); } |
na
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | // Plik: Renderer.cpp (...) void Renderer::LoadTexture(const std::string & filename) { std::cout << "Ładowanie obrazka z pliku " + filename + "\n"; // załaduj z pliku SDL_Surface* surface = IMG_Load(filename.c_str()); if (!surface) { (...) GLenum format; switch (surface->format->BytesPerPixel) { case 3: format = GL_RGB; // w PNG jest normalnie break; case 4: format = GL_RGBA; break; default: std::cerr << "Nieznany format pliku " + filename + "\n"; exit(1); } |
Jest to funkcja z biblioteki SDL_image, więc należy uaktualnić opcje budowania projektu (dodać wspomnianą bibliotekę do opcji konsolidatora):
1 2 3 4 5 6 | // Plik: SConstruct // zamieniamy env.MergeFlags("-lSDL -lGL -lGLU -lSDL_mixer"); // na env.MergeFlags("-lSDL -lGL -lGLU -lSDL_mixer -lSDL_image") |
Skoro zmieniliśmy nazwę pliku, to trzeba uaktualnić ją w metodzie App::Run:
1 2 3 4 5 6 | // Plik: App.cpp // zamieniamy const std::string atlas_filename = "data/tex.bmp"; // na const std::string atlas_filename = "data/tex.png"; |
Skoro jesteśmy przy temacie grafiki, warto zmienić jeszcze jedną rzecz z nią związaną. Włączmy obsługę przezroczystości w naszej grze. Z biblioteką OpenGL jest to dziecinnie proste. Jedno, o czym należy pamiętać, to aby odłożyć na stos atrybuty odpowiedzialne za włączenie przezroczystości oraz ustawienie odpowiedniego trybu. Dodane wiersze kodu zostały oznaczone gwiazdką "*":
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 26 27 28 29 30 31 32 33 34 35 36 37 38 | // Plik: Game.cpp // Fragment metody Game::Draw (...) glMatrixMode(GL_PROJECTION); glPushMatrix(); { * glPushAttrib(GL_COLOR_BUFFER_BIT); * { * glEnable(GL_BLEND); * glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); double player_x = -(m_stored_player_pos_x * Engine::Get().GetRenderer()->GetTileWidth() - 0.45); glTranslated(player_x, 0, 0); glMatrixMode(GL_MODELVIEW); m_level_view.SetLevel(m_level, m_stored_player_pos_x); m_level_view.Draw(m_stored_player_pos_x); // narysuj postać gracza m_player->Draw(); // narysuj pozostałe obiekty for (std::vector<EntityPtr>::const_iterator it = m_entities.begin(); it != m_entities.end(); ++it) { const EntityPtr e = *it; if (e->IsAlive()) { e->Draw(); } } * } * glPopAttrib(); } glMatrixMode(GL_PROJECTION); (...) |
Odkąd wynaleziono graficzny interfejs użytkownika, korzystanie z wielu aplikacji stało się prostsze. Chociaż wciąż wiele osób korzysta z możliwości podawania komend w konsoli, to wygoda jaką daje GUI sprawia, że warto zadbać o taki sposób komunikacji z użytkownikiem.
W tym artykule zaimplementowaliśmy podstawową kontrolkę -- SpriteButton, czyli przycisk na którym można wyświetlić obrazek. Wyodrębniliśmy także klasy bazowe dla widżetów oraz GUI tak, aby mogły zostać ponownie użyte w przyszłości -- na przykład do implementacji kolejnych kontrolek.
Teraz nasz edytor zapewnia już efektywny sposób komunikacji z graczem. Z planszy, którą można ukryć mamy możliwość wyboru który element chcemy dodać do planszy. Kiedy uznamy, że nasz poziom jest wystarczająco kompletny, wystarczy naciśnięcie jednego przycisku, aby z trybu edycji przełączyć się do gry i przetestować dzieło. Dzięki możliwości ustalenia pozycji początkowej gracza, możemy szybko rozegrać dowolny kawałek planszy.
Zachęcamy do własnych eksperymentów, realizacji pomysłów zarówno własnych jak i tych zebranych poniżej.
Masz pytanie, uwagę? Stworzyłeś poziom, którym chcesz się podzielić? A może zauważyłeś błąd? Powiedz o tym na forum [5].
Pobierz [6] końcowy kod źródłowy do tego artykułu.
Odnośniki:
[1] http://marcindev.blogspot.com/
[2] http://informatyka.wroc.pl/node/878
[3] http://informatyka.wroc.pl/node/1246
[4] http://informatyka.wroc.pl/upload/mmi/platf/12/12_start.zip
[5] http://informatyka.wroc.pl/forum/viewtopic.php?f=55&t=358
[6] http://informatyka.wroc.pl/upload/mmi/platf/12/12_final.zip