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ę!