Notatki programisty: Czuj się Disneyem, czyli o animacji słów kilka
Niniejszy wpis zaczniemy od omówienia tego jak działają animowane sprite’y w grach. Przechodząc do sedna sprawy: mechanizm animacji w prostych grach 2D polega na tym samym co i w kreskówkach. Cały sekret i magia. ;) Mamy animacje która podzielona jest na pojedyncze klatki które zmieniane są co określony przedział czasowy. W przypadku aplikacji, animację przeważnie zapisujemy do tekstury która później w kodzie jest odpowiednio obsługiwana. W niniejszym wpisie zaprezentuje jak będzie wyglądać nasza klasa do obsługi takiej tekstury. Plik graficzny prezentuje się jak poniżej:
Jak widać na powyższym obrazku, w jednym pliku zapisaliśmy więcej niż jedną animację. Oczywiście moglibyśmy każdą animację zapisywać w oddzielnych plikach graficznych aczkolwiek takie rozwiązanie, moim zdaniem, jest bardzo pracochłonne. Oto jak będzie wyglądać nasza klasa do obsługi tekstury:
class Animator { public: Animator( sf::Shape* shape, sf::Vector2f frameSize); void work(); void work(double externalIterator); void setAnimation(const int index); void setAnimationSpeed(const double newSpeed); sf::Shape* getShape() const; private: double m_animationSpeed; double m_frameIterator; int m_selectedAnimation; int m_frameCount; int m_animationCount; sf::Vector2f m_frameSize; sf::Shape* m_shape; void updateShape(const int multiplier); };
I implementacja:
Animator::Animator( sf::Shape* shapeObj, sf::Vector2f frameSize) : m_animationSpeed(1), m_frameIterator(0.0), m_selectedAnimation(0), m_frameCount(0), m_animationCount(0), m_frameSize(frameSize), m_shape(shapeObj) { const sf::Texture* shapeTexture = m_shape->getTexture(); if (shapeTexture != nullptr) { const sf::Vector2u textureSize = shapeTexture->getSize(); m_frameCount = textureSize.x / m_frameSize.x; m_animationCount = textureSize.y / m_frameSize.y; std::cout << "m_frameCount: " << m_frameCount << " m_animationCount: " << m_animationCount << "\n"; } else { std::cout << __func__ <<" -Texture of shape is NULL\n"; } } void Animator::work() { const int multiplier = (int)(m_frameIterator / this->m_frameCount); if (multiplier < this->m_frameCount) { updateShape(multiplier); } else { m_frameIterator = 0; } m_frameIterator += m_animationSpeed; } void Animator::work(double externalIterator) { const int multiplier = (int)(externalIterator / this->m_frameCount); if (multiplier < this->m_frameCount) { updateShape(multiplier); } else { externalIterator = 0; } externalIterator += m_animationSpeed; } void Animator::updateShape(const int multiplier) { m_shape->setTextureRect( sf::IntRect( multiplier * m_frameSize.x, m_selectedAnimation*m_frameSize.y, m_frameSize.x, m_frameSize.y)); } void Animator::setAnimation(const int index) { if(index < m_animationCount){ this->m_selectedAnimation = index; } else { std::cout << __func__ << " -index to hight"; } } void Animator::setAnimationSpeed(const double newSpeed) { if(newSpeed > 0){ this->m_animationSpeed = newSpeed; } else { std::cout << __func__ << " -newSpeed to hight"; } } sf::Shape* Animator::getShape() const { return this->m_shape; }
Mając przed oczyma szczegóły implementacji możemy przejść do objaśnienia jak klasa ma działać. Mianowicie będziemy posiadać obiekt graficzny z biblioteki SFML który będzie posiadać wyżej wspomnianą teksturę a który jednocześnie będzie pełnił roli powierzchni na której wyświetlana jest animacja. Coś jak ekran w starym kinie. Zadaniem naszej klasy będzie przycinanie tekstury i wyświetlanie odpowiedniej klatki we właściwym momencie. Przedział czasowy między jedną a drugą klatką będzie determinowany przez wyliczaną zmienną multiplier. Oczywiście można to przerobić na używanie struktury time_t aczkolwiek na chwilę obecną proponowane rozwiązanie okaże się dla nas wystarczające.
Do działania naszej klasy potrzebujemy: wyżej wymienionego obiektu przestrzeni jak i informacji jakiej wielkości jest pojedyncza klatka/ramka animacji. W przypadku naszego pliku rozmiar pojedynczej klatki wynosi 104 na 150 pikseli. Klasa posiada dość „sprytny” mechanizm. Mianowicie na podstawie informacji o wielkości pojedynczej klatki i wielkości tekstury, potrafi ona określić z ilu klatek składa się nasza animacja oraz to, ile w naszym pliku tych animacji się znajduje. Dzięki czemu mamy do dyspozycji mechanizm przełączania animacji, indeksowanych od zera w górę. Kod funkcji głównej prezentuje się następująco:
int main() { sf::RenderWindow window( sf::VideoMode(500, 500, 32), std::string("Animator Demo"), sf::Style::Close); sf::Texture playerTexture; playerTexture.loadFromFile("walk.png"); sf::Vector2f oneFrameSize(104.f, 150.f); sf::RectangleShape shapeForAnimation; shapeForAnimation.setSize(sf::Vector2f(200, 250.f)); shapeForAnimation.setOrigin( shapeForAnimation.getSize().x/2, shapeForAnimation.getSize().y/2); shapeForAnimation.setPosition(sf::Vector2f(250, 250.f)); shapeForAnimation.setTexture(&playerTexture); Animator animator( &shapeForAnimation, oneFrameSize); FpsStabilizer stabilizer(60); double animationSpeed = 1; while (window.isOpen()) { stabilizer.work(); animator.work(); sf::Event eventObj; while (window.pollEvent(eventObj)) { if (eventObj.type == sf::Event::Closed) { window.close(); } if(sf::Keyboard::isKeyPressed(sf::Keyboard::E)){ animator.setAnimation(0); } if(sf::Keyboard::isKeyPressed(sf::Keyboard::Q)){ animator.setAnimation(1); } if(sf::Keyboard::isKeyPressed(sf::Keyboard::A)){ animationSpeed += 0.05; animator.setAnimationSpeed(animationSpeed); } if(sf::Keyboard::isKeyPressed(sf::Keyboard::D)){ animationSpeed -= 0.05; animator.setAnimationSpeed(animationSpeed); } } window.clear(); window.draw(*animator.getShape()); window.display(); } return 0; }
Wszystko o czym pisałem wyżej zostało wykorzystane w kodzie. Tworzymy obiekt graficzny będącym płótnem na naszą animację, ładujemy teksturę, tworzymy animatora i viola, efekt wygląda tak jak poniżej. Oczywiście statyczny obrazek nie oddaje „skoku jakości” jakiego doświadczymy w przypadku dodania obsługi animacji do naszego dema.
Integracja z aplikacją
Klasę animującą tekstury mamy gotową, czas na zintegrowanie rozwiązania z istniejącą aplikacją SFML/Box2D. Dotychczas proces tworzenia obiektu renderującego wyglądał tak: mamy ciało fizyczne i na jego podstawie, generujemy obiekt do renderowania. Teraz sytuacja delikatnie się komplikuje gdyż obiekt graficzny który będzie reprezentować ciało definiujemy w miejscu tworzenia animatora. Dodatkowo, do tej pory mamy już napisany spory kawałek kodu stąd chcemy aby nasze nowe rozwiązanie było kompatybilne z dotychczasowymi osiągnięciami. Po kilku przemyśleniach stwierdziłem że najlepszym rozwiązaniem będzie:
- Zmiana nazwy DrawablePolygonBody na DrawableBody (śmieszna ale istotna zmiana) i zrobienie z niej klasy abstrakcyjnej.
- Utworzenie dwóch klas pochodnych od DrawableBody: DrawableBodyGenerated i DrawableBodyAnimated, pierwsza będzie przechowywać mechanizm używany dotychczas w przykładach, druga zaś będzie korzystać z nowej klasy animatora.
- Zmiana nazwy funkcji getRenderShape() na getRenderBody() w klasie IdentifiedBody.
Oto jak będą wyglądać deklaracje naszych nowych klas:
class DrawableBody { protected: const int METERES_TO_PIXELS = 50; const float PIXELS_TO_METERES = 0.02f; const float RADIANS_TO_PIXELS = 180 / b2_pi; const float PIXELS_TO_RADIANS = b2_pi / 180; public: DrawableBody(b2Body* baseBody); virtual void render(sf::RenderWindow& window) const = 0; void setColor(sf::Color newColor); void setRotate(const float32 angle); void setTexture(sf::Texture& texture); void setPosition(const float32 x, const float32 y); void setVisable(const bool value); bool isVisable() const; float getRotate() const; b2Body* getBody(); sf::Vector2f getPosition() const; void update(); protected: bool m_isVisable; b2Body* m_bodyPtr; std::shared_ptr<sf::Shape> m_renderObj; void synchronize(sf::Shape* view, b2Body* model) const; }; class DrawableBodyGenerated : public DrawableBody { public: DrawableBodyGenerated(b2Body* baseBody); void render(sf::RenderWindow& window) const; private: sf::ConvexShape* generateView(b2Body* body) const; }; class DrawableBodyAnimated : public DrawableBody { public: DrawableBodyAnimated( b2Body* baseBody, Animator* animator); void render(sf::RenderWindow &window) const; Animator* getAnimator() const; private: std::shared_ptr<Animator> m_animator; };
I implementacja klas pochodnych:
DrawableBodyAnimated::DrawableBodyAnimated( b2Body* baseBody, Animator* animator) : DrawableBody(baseBody), m_animator(animator) { m_renderObj.reset(m_animator->getShape()); } void DrawableBodyAnimated::render(sf::RenderWindow &window) const { if(isVisable()){ m_animator->work(); window.draw(*m_animator->getShape()); } } Animator* DrawableBodyAnimated::getAnimator() const { return m_animator.get(); } DrawableBodyGenerated::DrawableBodyGenerated(b2Body* baseBody): DrawableBody(baseBody) { m_renderObj = std::make_shared<sf::ConvexShape>( *generateView(m_bodyPtr)); } void DrawableBodyGenerated::render(sf::RenderWindow& window) const { if(isVisable()){ window.draw(*m_renderObj.get()); } } sf::ConvexShape* DrawableBodyGenerated::generateView(b2Body* body) const { sf::ConvexShape* bodyView = new sf::ConvexShape(); bodyView->setFillColor(sf::Color::White); for (b2Fixture* fixturePtr = body->GetFixtureList(); fixturePtr != nullptr; fixturePtr = fixturePtr->GetNext()) { b2Shape* shapeBuffer = fixturePtr->GetShape(); if(shapeBuffer->m_type == b2Shape::Type::e_polygon) { b2PolygonShape* realBodyShape = static_cast<b2PolygonShape*>(shapeBuffer); const int vertexCount = realBodyShape->GetVertexCount(); bodyView->setPointCount(vertexCount); for (int i = 0; i < vertexCount; ++i) { b2Vec2 currVerts = realBodyShape->GetVertex(i); float posX = currVerts.x * METERES_TO_PIXELS; float posY = currVerts.y * METERES_TO_PIXELS; bodyView->setPoint(i, sf::Vector2f(posX, posY)); } bodyView->setOrigin(0, 0); } } return bodyView; }
Ot proste wydzielenie konkretnej funkcjonalności i przeniesienie ich do nowych klas. Dodatkowo DrawableBody jest teraz abstrakcyjną przez co musimy zmodyfikować funkcje createArea() w której to używaliśmy DrawablePolygonBody/DrawableBody tak aby od teraz używać w niej nowej klasy DrawableBodyGenerated. Funkcja startowa naszego przykładu będzie wyglądać tak:
int startExample() { sf::RenderWindow* windowItem = new sf::RenderWindow( sf::VideoMode(800, 600, 32), std::string("SFML/Box2D - tech demo"), sf::Style::Default); std::shared_ptr<sf::RenderWindow> window(windowItem); std::shared_ptr<ControlKeys>playerControl(new ControlKeys()); FpsStabilizer stabilizer(60); /* Map section. */ ContactDetector myContactLister; std::unique_ptr<b2World> myWorld(createWorld()); myWorld.get()->SetContactListener(&myContactLister); Assets::Resources resources("data.zip"); std::vector<IdentifiedBody> listWorldBodies; createArea(myWorld.get(), listWorldBodies, resources); /* Player item. */ sf::Texture playerTexture; if(!playerTexture.loadFromFile("walk.png")){ std::cout << __func__ << " -walki.png problem\n"; } sf::Vector2f oneFrameSize(104.f, 150.f); sf::Vector2f playerBodySize(75.f, 100.f); sf::RectangleShape shapeForAnimation; shapeForAnimation.setSize(playerBodySize); shapeForAnimation.setOrigin( shapeForAnimation.getSize().x/2, shapeForAnimation.getSize().y/2); shapeForAnimation.setPosition(sf::Vector2f(250, 250.f)); shapeForAnimation.setTexture(&playerTexture); MovableBody playerItem( new DrawableBodyAnimated( createDynamicBody( myWorld.get(), playerBodySize.x, playerBodySize.y), new Animator( &shapeForAnimation, oneFrameSize)), BodyUserData::Type::Player); playerItem.getRenderBody()->setPosition(400.f, 10.f); playerItem.getMover()->setJumpForce(4.5f); /* Enemy A. */ MovableBody enemyItem( new DrawableBodyGenerated( createDynamicBody(myWorld.get(), 90, 60)), BodyUserData::Type::Enemy); enemyItem.getRenderBody()->setTexture( *resources.getTexture(Assets::Textures::Enemy)); enemyItem.getRenderBody()->setPosition(600.f, 50.f); enemyItem.getMover()->setJumpForce(3.f); enemyItem.getMover()->setMaxSpeed(2.5f); /* Enemy B. */ MovableBody enemyItemB( new DrawableBodyGenerated( createDynamicBody(myWorld.get(), 90, 60)), BodyUserData::Type::Enemy); enemyItemB.getRenderBody()->setTexture( *resources.getTexture(Assets::Textures::Enemy)); enemyItemB.getRenderBody()->setPosition(150.f, 50.f); enemyItemB.getMover()->setJumpForce(3.f); enemyItemB.getMover()->setMaxSpeed(2.5f); /* Ewidencja wrogow. */ std::vector<MovableBody> listEnemies; listEnemies.push_back(enemyItem); listEnemies.push_back(enemyItemB); sf::Color backgroundColor; while(window->isOpen()) { /* OTHER */ stabilizer.work(); backgroundColor = sf::Color::Black; for(MovableBody& item : listEnemies){ item.getRenderBody()->setColor(sf::Color::White); } /* EVENTS */ sf::Event myEvent; while(window->pollEvent(myEvent)) { if(myEvent.type == sf::Event::Closed){ window->close(); } } if(sf::Keyboard::isKeyPressed(playerControl->MOVE_JUMP)) { playerItem.getMover()->move( BodyMover::Direction::Jump); } if(sf::Keyboard::isKeyPressed(playerControl->MOVE_RIGHT)) { playerItem.getMover()->move( BodyMover::Direction::Right); DrawableBodyAnimated* ptr = (DrawableBodyAnimated*)playerItem.getRenderBody(); ptr->getAnimator()->setAnimation(0); } if(sf::Keyboard::isKeyPressed(playerControl->MOVE_LEFT)) { playerItem.getMover()->move( BodyMover::Direction::Left); DrawableBodyAnimated* ptr = (DrawableBodyAnimated*)playerItem.getRenderBody(); ptr->getAnimator()->setAnimation(1); } /* BOX2D */ beforeGameLoop(*myWorld.get()); for(auto& enemyItem : listEnemies) { break; } const bool contactCondition = (!myContactLister.isContactListIsEmpty()) && myContactLister.isContactListContains( ContactDetector::Contact::Type::PlayerTouchEnemy); if(contactCondition) { std::vector<ContactDetector::Contact::Info> enemyContacts = myContactLister.getContactList( ContactDetector::Contact::Type::PlayerTouchEnemy); if(!enemyContacts.empty()){ for(auto& contact : enemyContacts) { break; } } } /* RENDER */ window->clear(backgroundColor); playerItem.getRenderBody()->update(); playerItem.getRenderBody()->render(*window); for(auto& item : listEnemies){ item.getRenderBody()->update(); item.getRenderBody()->render(*window); } for(auto& item : listWorldBodies){ item.getRenderBody()->render(*window); } window->display(); } return 0; }
Po analizie nowych klas i poprzedniego przykładu duża cześć kodu powinna być zrozumiała. Jako że to pierwsze podejście integracji mechanizmów: plik tekstury na "żywcem" pobieramy z pliku a nie przez Assets::Resources. Co może budzić pewne zastrzeżenie to sposób w jaki pobieramy obiekt animatora. W ciemno zakładamy że obiekt DrawableBody jaki zawarty jest w playerItem jest na pewno typu DrawableBodyAnimated. Niestety żaden intaceof nas w tej sytuacji nie poratuje. Dodatkowo animacje zmieniamy też z dużym kredytem zaufania gdyż nie wiemy jaka animacja kryje się pod indeksem 1 czy 0. W tym miejscu pojawia się wymóg unifikowania i świadomego wykorzystania plików tekstur do animacji. Jeżeli w tekście nie zapomniałem o czymś wspomnieć, efekt naszej aplikacji powinien wyglądać następująco:
Jak zawsze, dzięki za uwagę!