W tym artykule zajmiemy się doszlifowaniem naszej gry, gdyż, jak wiadomo, diabeł tkwi w szczegółach. Po wprowadzonych zmianach gra będzie lepiej się prezentować a także sprawniej działać.
Poprzedni artykuł - Wykrywanie kolizji i obsługa jednostek [2] Następny artykuł - Dobrze mieć wybór... poziomu [3]
Zmiany, które wystąpią w tym artykule będą dotyczyły tego [4] kodu źródłowego, który jest efektem artykułu o wykrywaniu kolizji [2].
Pierwszą rzeczą, która doskwiera już po kilku pierwszych minutach gry, jest fakt występowania dużej ilości pocisków na ekranie. Z jednej strony jest to ułatwienie dla gracza - im więcej pocisków, tym większa szansa na ofiary wśród przeciwników. Jednak czy w ten sposób gra nie stanie się łatwa i nudna? I czy nie zabijemy w ten sposób jednostek, na których nam zależy? Kolejna sprawa to przejrzystość gry i "porządek" na ekranie. Dużo przyjemniej będzie się grać, jeśli na monitorze zobaczymy coś więcej niż masa pocisków. Taką sytuację przedstawia poniższy zrzut ekranu.
Jest kilka sposobów na uporanie się z tym problemem. Po pierwsze, możemy zezwolić graczowi na wystrzelenie tylko pewnej ilości pocisków. Pytanie brzmi: co się stanie po zużyciu ich wszystkich? Nie będzie czym strzelać. Druga możliwość to zliczanie odbić pocisku od pól na mapie, a po przekroczeniu pewnej wartości usunięcie go z gry. Jest to ciekawy pomysł, jednak bardziej wymagający niż trzecia koncepcja: usuwanie pocisku z gry po pewnym czasie. Tego chyba nie trzeba tłumaczyć, więc przejdźmy o jej realizacji. Wszystkie zmiany będą dotyczyły klasy PlayerBulletEntity. Na samym początku, dodajemy prywatne pole m_time_to_live odliczające czas do samozniszczenia:
1 2 3 4 | private: double m_time_to_live; // czas, który pozostał do samozniszczenia }; typedef boost::shared_ptr<PlayerBulletEntity> PlayerBulletEntityPtr; |
Następnie dodajemy domyślną wartość, która będzie nadawana w momencie tworzenia nowego pocisku, czyli w konstruktorze:
1 2 3 4 5 6 7 8 9 10 11 12 13 | class PlayerBulletEntity : public Entity { enum { DefaultXVelocity = 6, DefaultYVelocity = -2, DefaultXAcceleration = 0, DefaultYAcceleration = 0, DefaultTimeToLive = 2 // czas życia = 2s }; public: PlayerBulletEntity(double x, double y) : Entity(x, y, DefaultXVelocity, DefaultYVelocity, DefaultXAcceleration, DefaultYAcceleration), m_time_to_live(DefaultTimeToLive) { } |
Zdefiniowaną wartość należy aktualizować razem z całym obiektem. Akcja ta jest wykonywana przez wywołanie na obiekcie metody Update, do której jako argument przekazujemy czas, jaki upłynął od ostatniego wywołania. Oto ciało tej metody po zmianach:
1 2 3 4 5 6 7 8 9 10 11 12 | void Update(double dt, LevelPtr level) { // usuń obiekt jeżeli żyje zbyt długo m_time_to_live -= dt; if (m_time_to_live < 0) { SetIsDead(true); return; } // sprawdź kolizje i ustaw CheckCollisionsWithLevel(dt, level); SetPosition(GetNextXPosition(dt), GetNextYPosition(dt)); } |
Poniższy zrzut przedstawia sytuację po wykonaniu tych kroków. Rozgrywka jest znacznie czytelniejsza.
Podczas implementowania możliwości dodawania jednostek do gry, przyjęliśmy następujące założenie: nowy element powinien zostać dodany w takim miejscu, aby nie kolidował z mapą. Gdyby jednak doszło do takiej sytuacji, to zachowanie gry opisaliśmy jako niezdefiniowane. Osoby testujące naszą grę powinny zauważyć, że żadne warunki nie są sprawdzane podczas dodawania pocisków do gry. Czy może prowadzić to do nadużyć lub, co gorsza, powodować niestabilność aplikacji? Zobaczmy przykładową sytuację, w której występuje opisywany problem:
Pociski pojawiają się w miejscu podłoża, a co więcej nie mogą się stamtąd wydostać. Z pewnością nie jest to zamierzone działanie, więc należy je poprawić. Dodajmy do klasy Game metodę CanAddEntity sprawdzającą czy dodanie jednostki do gry nie spowoduje umieszczenie jej w pozycji, w której będzie kolidowała z mapą:
1 2 3 4 5 6 7 8 | bool CanAddEntity(EntityPtr entity) const { size_t curr_tile_x, curr_tile_y; entity->GetCurrentTile(&curr_tile_x, &curr_tile_y); if (m_level->Field(curr_tile_x, curr_tile_y) != FT::None) { return false; } return true; } |
Implementacja jest bardzo prosta. Przekształcamy pozycję gracza na konkretny kafel na mapie (metoda GetCurrentTile), a następnie sprawdzamy, czy jest o pusty. Jak łatwo zauważyć, problemy wciąż mogą występować, choć już w dużo mniejszej skali. Można sprawdzić to w praktyce - uzyskanie niepożądanego działanie jest dość trudne (aczkolwiek wykonalne).
Nowo dodaną metodę należy wywołać w momencie, gdy chcemy umieścić nową jednostkę w grze. Najczęściej będą to klasa dziedziczące po Creator. W naszym przypadku stosowną zmianę trzeba dodać w metodzie Create klasy PlayerBulletCreator:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | void Create(Game& game) { // stwórz jednostkę i nadaj jej odpowiednią prędkość // (zgodnie ze zwrotem postaci gracza) EntityPtr entity = Engine::Get().EntityFactory()-> CreateEntity(ET::PlayerBullet, m_x, m_y); double x_vel = m_vx < 0 ? -entity->GetDefaultXVelocity() : entity->GetDefaultXVelocity(); double y_vel = entity->GetDefaultYVelocity(); entity->SetVelocity(x_vel, y_vel); // dodaj pocisk do gry oraz odegraj dźwięk 'laser' if (game.CanAddEntity(entity)) { game.AddEntity(entity); Engine::Get().Sound()->PlaySfx("laser"); } } |
Grając w wersję gry powstałą po poprzednim artykule [2] nie sposób nie zauważyć pewnego przeoczenia, które znacząco wpływa na jakość rozgrywki. Kiedy gracz porusza się w prawą lub lewą stronę, jednocześnie strzelając, wszystko jest w porządku. Problem powstaje, gdy gracz się zatrzymuje, a jego stan ustawiany jest na PS::Stand. Wtedy kolejne pociski zostają wystrzelone zawsze w prawą stronę. Dzieje się tak, ponieważ informacja o kierunku ruchu postaci została utracona i nie wiadomo jaka powinna być prędkość nowego pocisku. Rozwiążemy ten problem dodając dwa dodatkowe stany oznaczające, że postać jest zwrócona w lewą bądź prawą stronę. Pozwoli to jednoznacznie zdecydować o kierunku wystrzelenia nowego pocisku. Dodajmy więc odpowiednie typy w pliku Types.h:
1 2 3 4 5 6 7 8 9 | namespace PS { enum PlayerState { Stand, GoLeft, GoRight, TurnLeft, // postać zwrócona w lewo TurnRight // postać zwrócona w prawo }; } |
Jak już wspomnieliśmy, informacja o kierunku ruchu tracona jest w momencie zatrzymania się postaci. Należy zatem uaktualnić metody, gdzie ustawiany jest stan PS::Stand zamiast PS::TurnLeft bądź PS::TurnRight. Funkcje te znajdują się w pliku Player.h. Przedstawiamy tutaj kilka linijek, które uległy zmianie:
1 2 3 4 5 6 7 8 9 10 11 12 | virtual void GoLeft() { m_vx -= GetDefaultXVelocity(); m_state=PS::GoLeft; } virtual void GoRight() { m_vx += GetDefaultXVelocity(); m_state=PS::GoRight; } virtual void StopLeft() { m_vx += GetDefaultXVelocity(); TurnLeft(); } virtual void StopRight() { m_vx -= GetDefaultXVelocity(); TurnRight(); } virtual void StopMovement() { m_vx = 0; m_state = PS::Stand;} void TurnLeft() { m_state=PS::TurnLeft; } void TurnRight() { m_state=PS::TurnRight; } void AllowToJump() { m_jump_allowed = true; } |
Następnie należy zapobiec sytuacji, w której z powodu zbyt małej prędkości ustawiany jest stan PS::Stand, a następnie na podstawie stanu aktualizowany jest stan odpowiedniego sprite'a. Czynności te odbywają się w metodzie Player::Update. Oto uaktualniony kawałek kodu, w którym nastąpiła zmiana:
Pokaż/ukryj kod1 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 | // ustal stan ruchu gracza na podstawie prędkości if (fabs(m_vx) < 0.001 && (m_state != PS::TurnLeft || m_state != PS::TurnRight)) { // nie zmieniamy stanu } else if (m_vx > 0.0) { m_state = PS::GoRight; } else { m_state = PS::GoLeft; } // uaktualnij animację switch (m_state) { case PS::Stand: m_stop->Update(dt); break; case PS::GoLeft: m_left->Update(dt); break; case PS::GoRight: m_right->Update(dt); break; case PS::TurnLeft: case PS::TurnRight: // animacja ma jedną klatkę, więc niczego nie aktualizujemy break; } |
Powinniśmy zadbać o wyświetlanie odpowiedniej klatki animacji, gdy postać znajduje się w jednym z nowo dodanych stanów. Jednak zamiast tworzyć całą serię nowych obrazków, na razie zadowolimy się wyświetleniem pojedynczej klatki. Będzie to pierwsza klatka animacji ruchu w lewo bądź w prawo, w zależności od ustawionego stanu. Oto kod, który zadba o wyświetlenie odpowiedniej klatki animacji (metoda Player::Draw):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | switch (m_state) { case PS::Stand: m_stop->DrawCurrentFrame(pos_x, pos_y, tile_width, tile_height); break; case PS::GoLeft: m_left->DrawCurrentFrame(pos_x, pos_y, tile_width, tile_height); break; case PS::GoRight: m_right->DrawCurrentFrame(pos_x, pos_y, tile_width, tile_height); break; case PS::TurnLeft: m_left->SetCurrentFrame(0); m_left->DrawCurrentFrame(pos_x, pos_y, tile_width, tile_height); break; case PS::TurnRight: m_right->SetCurrentFrame(0); m_right->DrawCurrentFrame(pos_x, pos_y, tile_width, tile_height); break; } |
Łatwo przeoczyć metodę Player::FireBullet. Jej działanie bazuje na aktualnej prędkości postaci. Kiedy jest ona mniejsza od zera, pocisk będzie leciał w lewą stronę, a kiedy większa od zera - w prawą. A co się stanie, jeśli prędkość jest dokładnie równa 0? W obecnej wersji pocisk zostałby wystrzelony w prawo. Nam jednak zależy, aby ta akcja wzięła pod uwagę nowe stany, w których może znaleźć się postać, gdyż mają one tutaj kluczowe znaczenie. Oto odświeżona wersja opisywanej metody:
void Player::FireBullet() { // GetX() oraz GetY() zwracają położenie lewego dolnego // narożnika postaci. W zależności od prędkości i stanu // postaci dodajemy pocisk po odpowiedniej stronie. double x, xvel; const double eps = 0.0001; // jakakolwiek prędkość if (m_state == PS::TurnLeft) { x = GetX() - .3; xvel = -eps; } else if (m_state == PS::TurnRight) { x = GetX() + .7; xvel = eps; } else { x = GetXVelocity() < 0 ? GetX() - .3 : GetX() + .7; xvel = GetXVelocity(); } const double y = GetY() + .5; AddCreator(CreatorPtr( new PlayerBulletCreator(x, y, xvel, GetYVelocity()))); }Poniżej znajduje się krótki filmik, na którym nasz bohater już wie, w którą stronę ma strzelać. ;-)
W obecnym stanie gry postać nie może zginąć. Co prawda zaimplementowany został mechanizm sprawdzający kolizję wrogich jednostek z graczem, ale jedyną akcją jaką podejmujemy w takiej sytuacji jest wyświetlenie napisu "Gracz zginal". Efekt ten nie jest zadowalający, więc damy naszej postaci ograniczoną liczbę żyć oraz zajmiemy się obsługą sytuacji, w której bohater ginie.
Do pól klasy Player dodajemy licznik żyć (m_lifes) oraz czas trwania nieśmiertelności (m_immortal_duration). W czasie, gdy bohater jest nieśmiertelny (m_is_immortal=true) nie będzie kolidował z jednostkami, ale wciąż będzie mógł poruszać się po mapie. Znajdowanie się postaci w tym stanie będzie objawiać się tym,że będzie ona migała. Zacznijmy jednak od dodania odpowiednich pól w pliku Player.h:
1 2 3 4 5 6 | int m_total_scores; // łączne zdobyte punkty bool m_is_immortal; // czy jest nieśmiertelny double m_immortal_duration; // czas przez który postać // już jest nieśmiertelna int m_lifes; // liczba żyć posiadanych // przez postać |
Następnie będziemy potrzebować dostępu do pola m_lifes z zewnątrz, więc dodajemy metodę GetLifesCount, a ciało funkcji LooseLife przenosimy do pliku Player.cpp:
1 2 3 4 5 6 7 8 9 | // plik Player.h // wystrzel pocisk void FireBullet(); // zwraca liczbę żyć bohatera int GetLifesCount() const { return m_lifes; } // gracz stracił życie - reakcja na zdarzenie void LooseLife(); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // metodę dodajemy na końcu pliku Player.cpp void Player::LooseLife() { // utrata jednego życia m_lifes--; // nieśmiertelność przez pewien czas m_is_immortal = true; m_immortal_duration = 0; // ustaw graczowi nową pozycję (respawn) // // TODO: przydałoby się lepsze losowanie nowej pozycji gracza // SetPosition(2, 2); } |
Dodane pola należy inicjalizować wartościami domyślnymi w konstruktorze
1 2 3 | m_total_scores(0), m_is_immortal(false), m_lifes(DefaultLifesCount), // NOWE |
Wykorzystana tu wartość DefaultLifesCount została zdefiniowana w wyliczeniu w klasie Player:
1 2 3 | enum { DefaultXVelocity = 4, DefaultYVelocity = 20, DefaultXAcceleration = 0, DefaultYAcceleration = -60, DefaultLifesCount = 2 }; |
Pozostaje jeszcze obsługa zdarzenia, kiedy bohater wpadnie w dziurę (przerwę) w poziomie. Do tej pory po prostu nie pozwalaliśmy mu spaść:
1 2 3 4 5 6 7 8 | // plik Player.cpp, metoda Update // jeżeli poniżej pierwszego kafla, to nie spadaj niżej. // Na razie ustalamy poziom na y=1, aby postać // nie uciekała za ekran if (m_y < 1) { m_y = 1; PlayerOnGround(); } |
Teraz jednak chcemy, aby mógł wpaść w dziurę. Wtedy odbierzemy mu jedno z żyć oraz ustalimy mu nową pozycję na planszy, od której będzie kontynuował grę (ang. respawn). Zajmie się tym metoda LooseLife. Ponadto uaktualnimy licznik m_immortal_duration o czas, jaki minął od ostatniej aktualizacji. Oto wersja powyższego kodu po opisanych modyfikacjach:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // jeżeli poniżej pierwszego kafla, to odbieramy życie. if (m_y < .5) { LooseLife(); PlayerOnGround(); } // uaktualnij informacje o nieśmiertelności const double immortality_time = 3; // 3 sekundy if (m_immortal_dutarion > immortality_time) { m_is_immortal = false; m_immortal_dutarion = 0; } else { m_immortal_dutarion += dt; } |
Ponieważ nie chcemy, aby gracz kolidował z innymi jednostkami, gdy miga, to dodajemy odpowiedni warunek na początku metody Game::CheckPlayerEntitiesCollisions:
1 2 3 4 5 6 | void Game::CheckPlayerEntitiesCollisions(double dt) { if (m_player->IsImmortal()) { return; } // (...) } |
Dodamy jeszcze wyświetlanie liczby żyć, które ma bohater oraz obsługę migania postaci, gdy jest nieśmiertelna. Nowa wersja metody Player::Draw przedstawia się następująco:
Pokaż/ukryj kod1 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 | void Player::Draw() const { // wypisz informację o liczbie punktów zdobytych przez gracza Text scores_text; scores_text.SetSize(0.05, 0.05); scores_text.DrawText("Punkty " + IntToStr(GetScores()), .01, .9); scores_text.DrawText("Zycia " + IntToStr(GetLifesCount()), .65, .9); // jeżeli bohater jest nieśmiertelny, to miga (rysuj-nie rysuj) if (IsImmortal() && int(m_immortal_duration * 10) % 2 == 1) { return; } // pobierz szerokość i wysokość kafla na ekranie RendererPtr renderer = Engine::Get().Renderer(); const double tile_width = renderer->GetTileWidth(); const double tile_height = renderer->GetTileHeight(); // wylicz pozycję gracza na ekranie const double pos_x = m_x * tile_width; const double pos_y = m_y * tile_height; switch (m_state) { case PS::Stand: m_stop->DrawCurrentFrame(pos_x, pos_y, tile_width, tile_height); break; case PS::GoLeft: m_left->DrawCurrentFrame(pos_x, pos_y, tile_width, tile_height); break; case PS::GoRight: m_right->DrawCurrentFrame(pos_x, pos_y, tile_width, tile_height); break; case PS::TurnLeft: m_left->SetCurrentFrame(0); m_left->DrawCurrentFrame(pos_x, pos_y, tile_width, tile_height); break; case PS::TurnRight: m_right->SetCurrentFrame(0); m_right->DrawCurrentFrame(pos_x, pos_y, tile_width, tile_height); break; } } |
W naszej grze brakuje jeszcze jednego ważnego elementu - przechodzenia do następnego poziomu. Ta istotna funkcjonalność w połączeniu z nowymi poziomami pozwoli zapewnić sporo dobrej zabawy. A więc, zaczynamy!
Do reprezentacji bohatera dodajemy metody, które pozwolą sprawdzić, czy gracz zakończył aktualny poziom i wykonać w tym momencie pewne akcje. Odpowiednie pole (należy pamiętać o jego inicjalizacji w konstruktorze wartością false) oraz funkcje dodajemy do pliku Player.h:
1 2 3 4 5 6 7 | // akcja wywoływana, kiedy gracz zakończy poziom // (np. odegranie fanfarów, wyświetlenia napisu, ... void LevelCompleted(); bool HasCompletedCurrentLevel() const { return m_is_level_completed; } |
Implementacja metody LevelCompleted w chwili obecnej jest dosyć prosta, jednak w przyszłości będziemy chcieli umieścić w niej dodatkowe akcje, dlatego definiujemy ją w pliku Player.cpp:
1 2 3 | void Player::LevelCompleted() { m_is_level_completed = true; } |
W naszej teksturze umieścimy specjalne pole, które będzie pozwalało na zakończenie aktualnego poziomu. Dodajemy je obok pola oznaczonego jako platforma_mid, więc konstruktor klasy SpriteConfig należy wzbogacić o wpis:
1 2 3 | Insert("end_of_level", SpriteConfigData(DL::Foreground, 1, 1, 32, 2 * 32, 32, 32, true)); |
Dodajemy nowe pole do mapy, więc należy także nadać mu numer, który będzie go identyfikował w pliku z poziomem. Robimy to w pliku Types.h. Oto odpowiedni kawałek kodu po modyfikacji:
1 2 3 4 5 6 7 8 9 10 11 12 | namespace FT { enum FieldType { None = 0, PlatformLeftEnd = 1, PlatformMidPart = 2, PlatformRightEnd = 3, EndOfLevel = 4, COUNT }; } |
Aby powiązać nazwę z odpowiednim numerem, dodajemy stosowny wpis podczas ładowania gry - metoda Game::Init:
1 2 3 | m_level_view.StoreSprite(FT::EndOfLevel, SpritePtr(new Sprite( engine.SpriteConfig()->Get("end_of_level")))); |
W celu sprawdzenia, czy poziom został przez gracza zakończony, wywołujemy na nim metodę HasCompletedCurrentLevel. Następnie możemy wykonać pewne akcje - będzie się to odbywać w metodzie Game::Update:
Pokaż/ukryj kod1 2 3 4 5 6 7 8 9 10 11 12 | // uaktualnij obiekt reprezentującego gracza m_player->Update(dt, m_level); // czy gracz zakończył aktualny poziom if (m_player->HasCompletedCurrentLevel()) { // akcja, jeżeli gracz zakończył poziom // np. załadowanie następnego :) } else if (m_player->GetLifesCount() < 1) { // jakieś akcje } |
Pozostaje nam tylko sprawdzić czy gracz wskoczył w odpowiedni kafel na mapie. Kolizje bohatera z poziomem są sprawdzane w metodzie Player::CheckCollisionsWithLevel. Na jej początku dodajemy odpowiedni warunek:
1 2 3 4 5 6 7 | void Player::CheckCollisionsWithLevel(double dt, LevelPtr level) { size_t x_tile, y_tile; GetCurrentTile(&x_tile, &y_tile); if (level->Field(x_tile, y_tile - 1) == FT::EndOfLevel) { LevelCompleted(); } |
Zauważmy, że tworząc możliwość wpisania własnego wyniku (klasa ScoreSubmit) wykorzystaliśmy założenie, że rozdzielczość okna to 600x400.
1 2 | double x = event.motion.x / 600.0; double y = 1.0 - event.motion.y / 400.0; |
Oczywiście wykorzysytwanie w kodzie takich założeń nie jest dobrym pomysłem. Gdyby nowy programista dołączył do projektu i chciał dodać możliwość zmiany rozdzielczości w menu głównym, to zapewne przeoczyłby te dwie linijki w ScoreSubmit.
Ten problem można rozwiązać na wiele sposobów. Najprościej jest zapamiętać szerokość i wysokość obrazu. Dlatego tworzymy nowy plik Window.h, w którym będze taka klasa:
Pokaż/ukryj kod1 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 | #ifndef __WINDOW_H__ #define __WINDOW_H__ class Window { public: explicit Window() { m_width = 0; m_height = 0; } size_t GetWidth() const { return m_width; } size_t GetHeight() const { return m_height; } void SetSize(size_t width, size_t height) { m_width = width; m_height = height; } private: size_t m_width; size_t m_height; }; typedef boost::shared_ptr<Window> WindowPtr; #endif /* __WINDOW_H__ */ |
Do klasy Engine dodajemy pole m_window typu WindowPtr oraz metodę Window, która zwraca zapamiętane okno. W metodzie Load dopisujemy
1 | m_window.reset(new Window::Window()); |
Teraz zmieniamy metodę App::Resize (plik App.cpp) tak, aby zamiast
1 2 | m_window_width = width; m_window_height = height; |
1 | Engine::Get().Window()->SetSize(width, height); |
Czyli zapamietujemmy szerokość i wysokość okna w klasie Enigne w polu m_window, zamiast bezpośrednio w klasie App. Dzięki temu możemy odwołać się do rozdzielczości okna w dowolnym miejscu programu.
Niezwykle ważną rolę w grze pełni muzyka wraz z innymi dźwiękami. Dlatego u nas będą teraz trzy rodzaje muzyki (w grze, w menu, podczas zapisywania wyniku) oraz sześć różnych odgłosów: strzał, skok, utrata życia, przejście do następnego poziomu, wygrana, zabicie przeciwnika.
Ponieważ dodawanie dźwięku polega tylko na edycji pliku sounds.txt oraz na napisaniu jednej linijki w C++ (którą już znasz) to pominę prezentację kodu. Całe źródło znajdziesz pod koniec tego artykułu.
Zmniejszymy głośność wszystkiego, z wyjątkiem muzyki. Dopisz:
1 | Mix_VolumeChunk(c, 32); |
Do metody Sound::LoadSfx tuż przed zapamietaniem wczytanego dźwięku. 32 to głośność w skali 0..128.
Zauważ, że gdy gracz zaczyna rozgrywkę i stoi na polu (1,2) to jest on na środku ekranu. Widać wówczas to co jest po lewej stronie mapy (poniżej współrzędnej x = 0). Aby poprawić ten drobny błąd wystarczy, że ustawimy taki stan początkowy, jaki gra ma po przejściu postacią do kolumny x=9 i powrocie na pozycję (1,2).
Wówczas m_stored_player_pos_x = 9 oraz m_max_x_pos = 9. Dlatego właśnie takie wartości zapisujemy jako początkowe dla tych pól (odpowiednio w konstruktorach klas Game i Player).
Co to za gra bez menu głównego. Wiesz już tak dużo na temat pisania gier, że stworzenie menu nie jest dla Ciebie problemem. Spróbuj stworzyć coś samemu. Aby menu było interaktywne możesz posiłkować się kodem klasy SubmitScore. Proste menu z tytułem "MENU" i trzema opcjami do wyboru (gra, hall of fame, wyjście), sterowane klawiaturą może wyglądać np tak jak dołączony kod do tego artykułu w plikach MainMenu.h i MainMenu.cpp. Nie odkrywamy tam ameryki, dlatego nie będziemy cytować tu tego kodu.
Denerwuje Cię fakt, że do tej pory piszemy niezależne od siebie fragmenty kodu. Tu poziom, tam hall of fame, w innym miejscu main menu itd. Skończona gra zawiera te wszystkie elementy, ale połączone ze sobą. Zazwyczaj widzimy main menu nie dla widoku, ale po to aby przejść z niego do pierwszego poziomu. Po ukończeniu poziomu gry rzadko się zapętlają czy wyłączają. Najczęściej następuje przejście do poziomu numer dwa (ewentualnie poprzedzone filmikiem itp.).
Dlatego teraz połączymy to, co napisaliśmy. W każdym momencie nasza gra będzie w jakimś stanie (w trakcie przechodzenia poziomu, w menu czy w hall of fame). Zrobimy to w najprostszy sposób. Wydzielamy wspólny interfejs tych stanów do nowej klasy (AppState).
Najpierw popatrzmy na diagram, aby zobaczyć jak to będzie wyglądało. Nie potrzebujemy szczegółowych rysunków, dlatego nie są zaznaczone typy argumentów czy zwracanych wartości - jedynie nazwy metod.
Widzimy, że klasa App zawiera obiekt typu AppState (któregoś z jego podtypów) - mówi o tym strzałka z rombem. Strzałka z trójkątem pokazuje, że klasy MainMenu, HallOfFame oraz Game dziedziczą po klasie AppState. W szczególności muszą implementować wszystkie metody czysto wirtualne z AppState, jeżeli chcemy tworzyć obiekty wspominanych typów. Zobaczmy jak to wygląda w kodzie:
Pokaż/ukryj kod1 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 | #ifndef __APP_STATE_H__ #define __APP_STATE_H__ #include <SDL/SDL.h> #include <boost/shared_ptr.hpp> class AppState { public: explicit AppState() : m_is_done(false) { } virtual void Init() = 0; virtual void Start() = 0; virtual void Draw() = 0; virtual bool Update(double dt) = 0; virtual void ProcessEvents(const SDL_Event& event) = 0; virtual boost::shared_ptr<AppState> NextAppState() const = 0; bool IsDone() const { return m_is_done; } void SetDone() { m_is_done = true; } private: bool m_is_done; }; typedef boost::shared_ptr<AppState> AppStatePtr; #endif /* __APP_STATE_H__ */ |
Teraz dodajemy do klasy state odpowiednie pole
1 | AppStatePtr m_app_state; |
Przetwarzanie eventów sprowadza się teraz do sprawdzenia czy dostaliśmy zdarzenie SDL_VIDEORESIZE. Jeżeli nie, to przekazujemy zdarzenie do aktualnego stanu:
1 2 3 4 5 6 7 8 9 10 11 | void App::ProcessEvents() { // przyjrzyj zdarzenia SDL_Event event; while (SDL_PollEvent(&event)) { if (event.type == SDL_VIDEORESIZE) { Resize(event.resize.w, event.resize.h); } else { m_app_state->ProcessEvents(event); } } } |
Rysowanie i uaktualnianie stanu polegają tylko na wywołaniu odpowiednich stanów. Natomiast metoda run na początek ustawia odpowiedni stan (np. menu)
1 2 3 | m_app_state.reset(new MainMenu); m_app_state->Init(); m_app_state->Start(); |
A następnie wykonuje pętlę główną, która różni się od poprzedniej implementacji tylko jednym szczegółem. Jest wykonywana w nieskończoność. Na początku pętli jest sprawdzenie czy aktualny stan się zakończył. Jeżeli tak to pobieramy następny (metoda NextAppState). Jeżeli dostaniemy pusty stan to kończymy całą grę. W przeciwnym wypadku wywołujemy metody Init oraz Start na nowym stanie i kontynuujemy obliczenia.
1 2 3 4 5 6 7 8 9 10 | while (true) { if (m_app_state->IsDone()) { m_app_state = m_app_state->NextAppState(); if (!m_app_state) { return; } m_app_state->Init(); m_app_state->Start(); } // dalej jest tak, jak poprzednio |
Oczywiście teraz musimy poprawić klasy, które mamy tak, aby były zgodne z interfejsem AppState. Zobaczmy to na przykładzie klasy HallOfFame.
Po pierwzse stan musi dziedziczyć publicznie po AppState:
1 | class HallOfFame : public AppState |
Po drugie musimy zdefiniować wszystkie metody zadeklarowane jako czysto wirtualne w klasie AppState (Init, Start, Draw, Update, ProcessEvents).
Metody Init i Start są puste. W metodzie Init dodalibyśmy kod, który służy do zapewnienia spójności stanu na początku. W metodzie start powinny być wpisane wszystkie akcje podejmowane przy włączaniu danego ekranu (np. rozpoczęcie odtwarzania nowej muzyki).
Metoda Draw pozostaje bez zmian. Metody Update i ProcessEvents wyglądają tak:
1 2 3 4 5 6 7 8 9 | bool HallOfFame::Update(double /* dt */) { return !IsDone(); } void HallOfFame::ProcessEvents(const SDL_Event& event) { if (event.type == SDL_KEYDOWN) { SetDone(); } } |
Co ten przykład pokazuje? Że tak naprawdę wprowadzenie koncepcji stanu ma spore znaczenie jeżeli chodzi o łatwość rozszerzania aplikacji, ale jednocześnie nie wymaga przepisywania całej gry. Wręcz przeciwnie - kod pozostaje w większości ten sam.
Gdybyśmy chcieli np. aby po poziomie nr 3 pojawiła się minigra, film i dopiero poziom 4 to przy takim kodzie jest to bardzo proste. Co więcej, dopisywanie nowych funkcjonalności nie powoduje konieczności zmiany kodu (wystarczy dopisywać nowe klasy). Zastanów się jak byś zrobił stan, w którym jest odtwarzany film.
Niestety nie wszystko jest takie proste. Aby zmienić poziom pierwszy na drugi - nie wystarczy aby poziom pierwszy zwrócił nowy stan (kolejny poziom). Trzeba też w jakiś sposób przekazać gracza do następnego poziomu (jego punkty i liczbę żyć) Jednocześnie trzeba ustawić mu stan na "stoi w miejscu".
Myślę, że "przerobienie" kodu poziomów i menu na model z AppState jest dobrym materiałem na ćwiczenie. Jest to kluczowa funkcjonalność, dlatego tym razem kod wynikowy artykułu zawiera rozwiązanie.
W każdej dobrej grze istnieje system nagród i kar (ogólnie , które wpływają na zachowanie się postaci. Jest to bardzo ciekawe urozmaicenie zachęcające gracza do kontynuowania gry, więc dodamy podobną funkcjonalność także do naszej gry. Po wskoczeniu w odpowiedni element na planszy, postać będzie strzelała dwoma pociskami, a nie, jak wcześniej, jednym. Dodajmy więc klasę reprezentującą to ulepszenie (ang upgrade):
1 2 3 4 5 6 7 8 | class TwinShotUpgrade : public Entity { public: TwinShotUpgrade(double x, double y) : Entity(x,y, 0, 0) { } ET::EntityType GetType() const { return ET::TwinShot; } }; |
Aby stworzyć instancję typu TwinShotUpgrade powinniśmy poinformować naszą fabrykę obiektów o tym, jak należy to robić. Dodajemy więc nowy typ do pliku Types.h oraz związujemy go z ciągiem znaków, przez który będzie reprezentowany w pliku z jednostkami dla odpowiedniego poziomu. Oto odpowiednie kawałki kodu w pliku EntityFactory.cpp:
1 2 3 4 5 6 7 8 9 10 11 | } else if (type == ET::TwinShot) { ptr.reset(new TwinShotUpgrade(x, y)); SpritePtr bullet = GetSpriteByName("twinshot_upgrade"); ptr->SetSprites(bullet, bullet, bullet); } // (...) } else if(name=="twinshot_upgrade") { return CreateEntity(ET::TwinShot, x, y); } |
Teraz zapewnimy istnienie odpowiednich pól i metod w klasie Player. Dodajemy m_twin_shot_enabled jako zmienną instancyjną, które w konstruktorze inicjalizujemy na false (czyli bohater na początku nie ma upgrade'u). Umieszczamy także kilka metod, które będą pozwalały sprawnie zarządzać implementowanym dodatkiem. Umieszczamy je w pliku Player.y:
1 2 3 4 | bool IsImmortal() const { return m_is_immortal; } void EnableTwinShot() { m_twin_shot_enabled = true; } void DisableTwinShot() { m_twin_shot_enabled = false; } bool IsTwinShotEnabled() const { return m_twin_shot_enabled; } |
Mając odpowiednie metody możemy przejść do wykrywania kolizji z obiektem klasy TwinShotUpgrade. Upgrade zostanie przyznany postaci, kiedy ta w niego wskoczy (obojętnie od której strony), czyli kiedy ich AABB będą się przecinały. Kod odpowiadający temu opisowi jest następujący:
Pokaż/ukryj kod1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | void Game::CheckPlayerEntitiesCollisions(double dt) { // (...) for (std::vector<EntityPtr>::iterator it = m_entities.begin(); it != m_entities.end(); ++it) { EntityPtr entity = *it; const ET::EntityType entity_type = entity->GetType(); if (entity_type == ET::PlayerBullet) { // postać nie koliduje ze swoimi pociskami continue; } else if (entity_type == ET::TwinShot) { // czy gracz wziął bonus TwinShot if (m_player->GetAabb().Collides(entity->GetAabb())) { m_player->EnableTwinShot(); entity->SetIsDead(true); } continue; } // (...) |
Pozostały nam jeszcze dwie rzeczy do zrobienia. Pierwsza z nich to wystrzelenie drugiego pocisku, jeżeli postać posiada odpowiedni upgrade. Za dodawanie pocisków odpowiedzialna jest metoda Player::FireBullet. Aby zamiast jednego strzału oddać dwa, należy ostatnie linijki tej metody zamienić na następujące:
1 2 3 4 5 6 7 8 9 10 | void Player::FireBullet() { // (...) const double y = GetY() + .5; AddCreator(CreatorPtr(new PlayerBulletCreator(x, y, xvel, 0))); if (IsTwinShotEnabled()) { AddCreator(CreatorPtr(new PlayerBulletCreator( x, y + .5, xvel + 2, 0 ))); } } |
Drugi, ostatnie element do wykonania, to przypisanie odpowiedniego sprite'a do nazwy twinshot_upgrade. Wszystkie tego typu powiązania dodajemy w konstruktorze klasy SpriteConfig:
1 2 3 | Insert("twinshot_upgrade", SpriteConfigData(DL::Entity, 2, 0.3, 0 * 32, 13 * 32, 32, 32, true)); |
Grając w różne gry komputerowe dochodzimy do wniosku, że jedne z nich wciągają na długie godziny, a inne wyłączamy po chwili. Co o tym decyduje? Nie sposób udzielić jednoznacznej odpowiedzi. Istnieją gry, które mimo dobrej oprawy graficznej wyłączamy po kilku minutach, ale są i takie, u których grafika pozostawia wiele do życzenia, a jednak możemy grać w nie bez końca. Jedno natomiast jest pewne - choć grafika nie przesądza o popularności gry, to w znacznym stopniu wpływa na jej odbiór. Dlatego w naszej grze podmienimy grafikę.
Gdyby każdy z poprawianych przez nas dzisiaj szczegółów występował osobno, można by go zaniedbać. Jednak zignorowanie ich wszystkich razem w znaczący sposób obniża jakość rozgrywki. Dlatego w procesie tworzenia aplikacji (nie tylko gier), trzeba co jakiś czas spojrzeć na nią krytycznym okiem. Nawet niewielkie usprawienia czy poprawienie drobnego błędu uprzyjemnia korzystanie z programu oraz podnosi jego efektywność. Pamiętajmy zatem o tym mądrym zdaniu: "Diabeł tkwi w szczegółach" :-)
Kod źródłowy do tego artykułu znajduje się pod tym adresem [5]. Poniżej znajduje się krótki filmik z gry :)
Odnośniki:
[1] http://marcindev.blogspot.com/
[2] http://informatyka.wroc.pl/node/477
[3] http://informatyka.wroc.pl/node/615
[4] http://informatyka.wroc.pl/upload/mmi/platf/07_start.zip
[5] http://informatyka.wroc.pl/upload/mmi/platf/07_final.zip