Pull to refresh

Пчела на работе, разработка игр на SFML C++

Reading time14 min
Views7.1K

Часть 3 "Покадровая анимация"

Предыдущая тема

Следующая тема

Управление временем

Одним из важных элементов при разработке игровой анимации является время. Предположим, что программист по имени Фёдор при написании многопользовательской гоночной игры упустил такое понятие, как привязка игровой логики ко времени.

Протестировав программу на своём компьютере и убедившись, что всё работает корректно, он рассылает игру своим друзьям. Его друг Василий недавно купивший компьютер с быстродействующим процессором, решает сыграть с Фёдором в его игру. Два друга выбрав себе гоночные машины начинают игру. Машина Василия со скоростью света устремляется к финишу, в то время как машина Фёдора медленно плетётся где-то позади. В итоге Василий выигрывает гонку.  Позже Фёдор понимает, что компьютер Василия выполнял код программы намного быстрее, поэтому его машина так быстро выиграла гонку. В дальнейшем Фёдор больше не упускал такой важный элемент как время. 

Давайте рассмотрим ошибку Фёдора на примере программного кода.

#include <SFML/Graphics.hpp>
using namespace sf;
int main()
{
    RenderWindow window(VideoMode(1280, 720), L"Машинка", Style::Default);
    RectangleShape car;
    Texture texcar;
    texcar.loadFromFile("car.png");
    car.setSize(Vector2f(200, 100));
    car.setTexture(&texcar);
    car.setPosition(10, 300);
     while (window.isOpen())
    {
        Event event;
        while (window.pollEvent(event))
        {
            if (event.type == Event::Closed)    window.close();
        }
        car.move(1, 0);
        if (car.getPosition().x > 1280) car.setPosition(10,300);
        window.clear(Color::Blue);
        window.draw(car);
        window.display();
    }
    return 0;
}

Данная программа перемещает графический объект на один пиксель каждую итерацию игрового цикла, поэтому скорость его обработки зависит от процессора и видео карты. Если на одном компьютере этот код будет работать со скоростью 30 кадров в секунду, что позволяет переместиться объекту на 30 пикселей, то на другом компьютере тот же код будет работать со скоростью 60 кадров в секунду, удваивая пройденное расстояние за то же время. Для решения этой проблемы, нужно привязать перемещение объекта ко времени, что мы и сделали в следующем коде.

    // Код из предыдущей программы
    Time elapsedTime;
    Clock clock;
    while (window.isOpen())
    {
        Event event;
        while (window.pollEvent(event))
        {
            if (event.type == Event::Closed)    window.close();
        }
        Time deltaTime = clock.restart();
        elapsedTime += deltaTime;
        if (elapsedTime > milliseconds(5)) 
        {
            car.move(1, 0);
            elapsedTime = milliseconds(0); 
        }
      // Код из предыдущей программы
      }
      // Код из предыдущей программы

В этом примере мы используем два класса связанных со временем - класс Time и класс Clock. Класс Time служит для хранения времени, которое мы можем преобразовывать с помощью специальных методов в секунды - asSeconds(), миллисекунды - asMilliseconds(), микросекунды - asMicroseconds(), а также проделывать со временем  арифметические операции. Класс Clock предоставляет интерфейс для измерения интервала прошедшего времени используя часы операционной системы. Метод класса getElapsedTime()  возвращает текущее время, а метод restart(), сбрасывает интервал прошедшего времени.

В примере мы прибавляем полученное время deltaTime к переменной времени elapsedTime. Обратите внимание, что при инициализации переменной deltaTime, мы получаем не прошедшее время, а единицу интервала времени благодаря методу restart(). Если значение переменной elapsedTime превысит 5 миллисекунд, программа выполнит перемещение объекта на один пиксель и обнулит значение переменной подсчёта интервала времени elapsedTime.

Взгляните ещё на один пример кода с этими двумя классами.

    Time elapsedTime;
    Clock clock;
    while (window.isOpen())
    {
        Event event;
        while (window.pollEvent(event))
        {
            if (event.type == Event::Closed)    window.close();
        }
        elapsedTime = clock.getElapsedTime();
        if (elapsedTime > milliseconds(5)) 
        {
            car.move(1, 0);
            elapsedTime = clock.restart();
        }

В данном примере, переменной времени elapsedTime присваивается прошедшее время методом getElapsedTime(). Если значение переменной elapsedTime превысит 5 миллисекунд, тогда выполнится перемещение объекта и переменная elapsedTime будет обнулена используя метод restart(), тем самым мы сбрасываем значение прошедшего интервала времени в методе getElapsedTime().

Анимация спрайтов

В наше время существуют разные виды анимации. Однако классическая анимация заключается в рисовании картинок, незначительно отличающихся друг от друга и выводе их одна за другой на экран. Именно этот подход мы будем использовать, в нашей игровой анимации. 

Для создания анимации нам понадобится класс Sprite. Его особенность заключается в том, что объекты типа Sprite не могут существовать без текстуры, например так как это возможно с объектами RectangleShape. Объекты типа Sprite при получении текстуры принимают её размер в качестве своих параметров, также при необходимости можно отобразить только часть картинки объекта Sprite, этот метод мы будем использовать для создания анимации.

Рассмотрим следующий код:

#include "AssetManager.h"
#include <SFML/Graphics.hpp>
using namespace sf;
int main()
{
    AssetManager manager;
    RenderWindow window(VideoMode(600, 600), L"Анимация", Style::Default);
    
    Vector2i spriteSize(255,255);
    Sprite sprite(AssetManager::GetTexture("frau.png"));
   
    sprite.setTextureRect(IntRect(0,0, spriteSize.x, spriteSize.y));
    sprite.setPosition(static_cast<float>(window.getSize().x) / 2 - sprite.getGlobalBounds().width / 2,
                       static_cast<float>(window.getSize().y) / 2 - sprite.getGlobalBounds().height / 2);
    int frameNum = 6;
    float animationDuration = 1;
    
    Time elapsedTime;
    Clock clock;
    while (window.isOpen())
    {
        Event event;
        while (window.pollEvent(event))
        {
            if (event.type == Event::Closed)    window.close();
        }
        Time deltaTime = clock.restart();      
        elapsedTime += deltaTime;
        float timeAsSecond = elapsedTime.asSeconds();
        
        int animFrame = static_cast<int>((timeAsSecond/animationDuration)* static_cast<float>(frameNum))% frameNum;
        
        sprite.setTextureRect(IntRect(animFrame*spriteSize.x,0,spriteSize.x,spriteSize.y));
        
        window.clear(Color::Blue);
        window.draw(sprite);
        window.display();
    }
    return 0;
}

В этом примере создана анимация бегущей девочки. Обычно анимация хранится в одном файле и каждый её кадр имеет одинаковый размер. В нашем случае текстура спрайта имеет 6 кадров размером 255 на 255 пикселей, которые воспроизводятся в течение одной секунды. Исходя из этого присваиваем эти значения переменным: frameNum - количество кадров и spriteSize - размер кадра анимации. В коде программы мы используем метод AssetManager::GetTexture() для загрузки текстуры  frau.png  с кадрами анимации в объект типа Sprite.

 Вот так выглядит текстура из файла frau.png:

А вот так выглядит анимация:

Метод setTextureRect() устанавливает прямоугольник отображаемого кадра анимации на нулевой кадр. В параметрах метода первые два значения - это координаты прямоугольника видимости кадра анимации, а два других ширина и высота этого прямоугольника. Структура IntRect () — это служебный класс для создания двумерных прямоугольников с целочисленными координатами.

Далее мы будем время от времени перемещать этот прямоугольник по текстуре, чтобы имитировать бег девочки.

В коде программы мы инициализируем дельту времени – переменную deltaTime единицей прошедшего времени с момента обнуления временного интервала. Далее добавляем ее к переменной измерения интервала времени с последнего показа кадра анимации - elapsedTime. Создаём вещественную переменную timeAsSecond и присваиваем ей накопленное время в секундах.

 Следующей формулой определяем номер кадра анимации, который будет показан в графическом окне:

Теперь мы можем установить прямоугольник отображения участка текстуры объекта Sprite на необходимый кадр анимации:

Рассмотрим, как работает формула вычисления кадра анимации. Берём условно, что с момента показа анимации прошло 0.4 секунды. Согласно формуле берём 0.4 секунды и делим на время показа всех кадров анимации, т.е. на 1 секунду.

0.4 / 1 = 0.4

Частное 0.4 умножаем на количество кадров в анимации, в нашем случае это 6.

0.4 * 6 = 2.4

Округляем полученное число в меньшую сторону, использовав приведение вещественного значения к целому типу используя операцию static_cast<>() и получаем целое число 2. Последним действием делим 2 на количество кадров в анимации используя оператор получения остатка от деления (%) и получаем 2.

2 % 6 = 2 

Таким образом мы получаем индекс кадра анимации, который равен двум. В параметре метода setTextureRect() вычисляем положения кадра анимации умножив индекс кадра анимации на ширину кадра анимации.

animFrame * spriteSize.x = 2 * 255 = 510

 Рассмотрим ещё один пример.

Предположим, что с момента показа анимации прошло 3.8 секунд. Согласно формуле, берём 3.8 секунд и делим на время показа всех кадров анимации, что соответствует 1 секунде.

3.8 / 1 = 3.8

Следующим действием умножаем на количество кадров в анимации в нашем случае на 6.

2.5 * 6 = 15

Последним нашим действием мы делим результат на количество кадров в анимации используя оператор получения остатка от деления (%).

  15 % 6 = 3

Как получили:

12 / 6 = 2

15 - 12 = 3

Таким образом мы получаем индекс кадра анимации, который равен трём. В параметре метода setTextureRect() вычисляем положения  кадра в текстуре объекта Sprite, умножив его ширину 255 на индекс 3.

Теперь когда мы разобрались как работает анимация, можем приступить к написанию  специального класса Animator, для автоматизации процесса анимации.

Класс Animator

 Перечислим что должен уметь класс Animator:

  • анимировать спрайт из одного объекта или нескольких текстурных объектов;

  • поддерживать анимацию с переменной продолжительностью анимации и количеством кадров;

  • содержать несколько анимаций;

  • переключаться между анимациями;

  • анимировать горизонтальные и вертикальные области спрайта;

  • у каждого спрайта должен быть свой объект - аниматор;

  • автоматически генерировать прямоугольник кадра анимации в текстуре спрайта.

 Поскольку мы хотим содержать несколько анимаций для каждого объекта - Animator, создаём структуру Animation для хранения свойств каждой отдельной анимации.

Каждая анимация должна иметь продолжительность показа всех кадров анимации, список кадров, текстуру (используемую анимацией), информацию об итерации (если анимация будет проигрываться циклично) и возможность использовать ссылки на эту анимацию. Учитывая вышеперечисленные требования создаём структуру Animation.

#pragma once
#include<iostream>
#include<vector>
#include<list>
#include <SFML/Graphics.hpp>
#include "AssetManager.h"

class Animator
{
public:
	struct Animation
	{
		std::string m_Name;
		std::string m_TextureName;
		std::vector<sf::IntRect> m_Frames;
		sf::Time m_Duration;
		bool m_Looping;
		Animation(std::string const& name, std::string const& textureName,
			sf::Time const& duration, bool looping) :m_Name(name), m_TextureName(textureName),
			m_Duration(duration), m_Looping(looping){}
		void AddFrames(sf::Vector2i const& startFrom,
			sf::Vector2i const& frameSize, unsigned int frames, unsigned int traccia)
		{
			sf::Vector2i  current = startFrom;
			for (unsigned int t = 0; t < traccia; t++) {
				for (unsigned int i = 0; i < frames; i++)
				{
					m_Frames.push_back(sf::IntRect(current.x, current.y, frameSize.x, frameSize.y));
					current.x += frameSize.x;
				}
				current.y += frameSize.y;
				current.x = startFrom.x;
			}
		}
	};

Структура Animation состоит из свойств:

m_Name - имя анимации;

m_TextureName -  путь и имя файла текстуры анимации;

вектора m_Frames -  последовательность координат и размера прямоугольной области отображаемого кадра анимации;

m_Duration - время проигрывания всех кадров анимации;

m_Looping - включает разрешение на цикличное воспроизведение анимации;

метода AddFrames - принимает в параметрах координаты и размер прямоугольной области кадра, количество кадров по горизонтали, количество горизонталей с кадрами анимации, после чего записывает положение и размер прямоугольников отображения кадров анимации в вектор m_Frames.

 Со структурой мы разобрались, теперь можем продолжать работать над классом Animator.

Первое, что нужно сделать, чтобы Animator имел возможность создавать и хранить анимации для последующего использования. Кроме того, поскольку анимация зависит от времени, данный класс должен содержать метод Update(), который вызывается каждый кадр и в параметрах получает единицу прошедшего времени.

Учитывая всё перечисленное, пишем следующий код:

private:

	Animator::Animation* FindAnimation(std::string const& name);
	void SwitchAnimation(Animator::Animation* animation);
	sf::Sprite& m_Sprite;
	sf::Time m_CurrentTime;
	std::list<Animator::Animation> m_Animations;
	Animator::Animation* m_CurrentAnimation;
	bool endAnim = false;

Методы FindAnimation()  и SwitchAnimation() рассмотрим позже. Сейчас нас больше интересуют свойства класса. Как видите, объект Sprite в классе Animator отсутствует, есть только ссылка Sprite & m_Sprite. Это означает, что объект Sprite должен быть создан вне класса Animator и передан в класс через конструктор. Следующее свойство - счетчик интервала времени m_CurrentTime, он необходим для создания небольших пауз между отображением кадров анимации. Контейнер list<Animator::Animation> и указатель на текущую анимацию m_CurrentAnimation. Обратите внимание, что мы не используем vector<>для хранения типа Animator::Animation, а используем контенер list<>. Это потому, что мы не можем сохранить указатели и ссылки на элементы в контейнер vector<>, так как они станут недействительными. В контейнере list<> такой проблемы нет, его реализация гарантирует, что указатели и ссылки останутся действительными даже после добавления и удаления элементов из него. Свойство endAnim, содержит текущее состояние анимации, которая должна проигрываться только один раз, если анимация проиграна возвращает значение true.

Теперь давайте рассмотрим код с описанием открытых методов класса Animator.

public:

	struct Animation
	{
    // код структуры
	};

	explicit Animator(sf::Sprite& sprite);

	Animator::Animation& CreateAnimation(std::string const& name,
		std::string const& textureName, sf::Time const& duration, 
		bool loop = false	);

	void Update(sf::Time const& dt);
	bool SwitchAnimation(std::string const& name);
	std::string GetCurrentAnimationName() const;
	void restart();
	bool getEndAnim() const
	{
		return endAnim;
	}

Первое, что бросается в глаза, это структура Animation, её мы рассматривали ранее. Следующая строка содержит объявление конструктора, который в параметрах получает ссылку на объект Sprite. Этой ссылкой инициализируется свойство данного класса m_Sprite. Метод CreateAnimation() создает анимацию из полученных параметров и добавляет ее в контейнер list<>, возвращая нам ссылку на эту анимацию. Метод Update() обрабатывает логику выбора правильного кадра в нужный момент времени. Метод SwitchAnimation(std::string const& name)  меняет текущую анимацию на анимацию с заданным именем. Метод GetCurrentAnimationName() возвращает имя текущей анимации, метод restart() перезапускает анимацию, у которой нет цикличного воспроизведения. Метод  getEndAnim() возвращает состояние проигрыша анимации.

Теперь мы можем рассмотреть реализацию методов класса Аниматор.

 Начнём с конструктора:

Animator::Animator(sf::Sprite& sprite) : m_CurrentAnimation(nullptr), 
m_Sprite(sprite) {}

В конструкторе мы инициализируем свойства класса. Обратите внимание, что ссылка на Sprite должна быть обязательно инициализирована, иначе код не скомпилируется. Также всегда присваивайте указателям nullptr.

Animator::Animation& Animator::CreateAnimation(std::string const& name, 
	std::string const& textureName, sf::Time const& duration, bool loop)
{
	m_Animations.emplace_back(name, textureName, duration, loop);

	if (m_CurrentAnimation == nullptr) SwitchAnimation(&m_Animations.back());

	return m_Animations.back();
}

Метод CreateAnimation() создает анимацию, используя ряд параметров. Как мы установили ранее, каждой анимации нужно имя, чтобы мы могли ссылаться на нее вне класса. Кроме того, каждая анимация имеет связанную с ней текстуру, время анимации всех кадров и возможность зациклить воспроизведение анимации. Всеми этими параметрами мы инициализируем новый экземпляр анимации и помещаем его в свойство m_Animations, добавляя его в динамический массив контейнера list. Если это наша первая анимация, мы устанавливаем её в качестве текущей используя метод SwitchAnimation(Animation*), что гарантирует дальнейшую работу метода Update(). Последняя строка метода CreateAnimation() возвращает ссылку на только что созданную анимацию. Это даёт возможность проводить дальнейшие манипуляции с анимацией, добавляя ей кадры или изменять некоторые из ее начальных значений.

Рассмотрим метод SwitchAnimation(Animator::Animation* animation):

void Animator::SwitchAnimation(Animator::Animation* animation)
{
	if (animation != nullptr)
	{
		m_Sprite.setTexture(AssetManager::GetTexture(animation->m_TextureName));
	}

	m_CurrentAnimation = animation;
	m_CurrentTime = sf::Time::Zero; 
}

Поскольку данным методом мы переключаемся на новую анимацию, нам нужно изменить текстуру спрайта анимации. Однако перед этим мы сделаем проверку указателя animation на неравенство нулю (nullptr для указателей). Если animation не является nullptr, тогда мы можем безопасно вызвать текстуру m_TextureName используя метод класса менеджер ресурсов - GetTexture(). Далее мы устанавливаем указатель m_CurrentAnimation на animation и сбрасываем время.

bool Animator::SwitchAnimation(std::string const& name)
{
	auto animation = FindAnimation(name);
	if (animation != nullptr)
	{
		SwitchAnimation(animation);
		return true;
	}
	return false;
}

Метод SwitchAnimation( std::string const& name )  пытается по имени найти нужную анимацию. Если это не удается, он возвращает  значение false. В случае успеха, он использует указатель animation, чтобы переключиться на найденную анимацию вызвав метод SwitchAnimation(Animator::Animation* animation) и возвращает true,  чтобы сообщить об успешном переключении анимации.

Animator::Animation* Animator::FindAnimation(std::string const& name)
{
	for (auto it = m_Animations.begin(); it != m_Animations.end(); ++it)
	{
		if (it->m_Name == name) return &*it;
	}

	return nullptr;
}

Метод FindAnimation() осуществляет поиск заданной анимации по имени в контейнере list. Обратите внимание, что в текущей реализации метода необходимо избегать анимации с одинаковыми именами, так как не будет возможности получить к ним доступ.

Далее рассмотрим метод, который возвращает имя проигрываемой анимации:

std::string Animator::GetCurrentAnimationName() const
{
	if (m_CurrentAnimation != nullptr) return m_CurrentAnimation->m_Name;
	return "";
}

Метод restart() позволяет повторно запустить анимацию если не задан параметр разрешающий её цикличное воспроизведение:

void Animator::restart()
{
	m_CurrentTime = sf::Time::Zero; 
	endAnim = false;
}

После того, как мы рассмотрели все вспомогательные методы класса Animator, можем приступить к рассмотрению метода Update():

void Animator::Update(sf::Time const& dt)
{
	if (m_CurrentAnimation == nullptr) return;

	m_CurrentTime += dt;

	float scaledTime = (m_CurrentTime.asSeconds() / m_CurrentAnimation->m_Duration.asSeconds());
	auto numFrames = static_cast<int>(m_CurrentAnimation->m_Frames.size());
	auto currentFrame = static_cast<int>(scaledTime * numFrames);

	if (m_CurrentAnimation->m_Looping) currentFrame %= numFrames;
	else
		if (currentFrame >= numFrames) { currentFrame = numFrames - 1; endAnim = true; }

	m_Sprite.setTextureRect(m_CurrentAnimation->m_Frames[currentFrame]);

}

Метод Update() начинается с проверки указателя m_CurrentAnimation. Если его значение равно nullptr, тогда метод заканчивает свою работу. Если присутствует указатель на анимацию, тогда добавляем переменную дельта времени к переменной общего учёта интервала времени m_CurrentTime. В переменную scaledTime записываем рассчитанный масштаб времени в секундах. В следующей формуле, находим текущий кадр анимации путем умножения масштаба времени scaledTime на общее количество кадров анимации numFrames, и округляем это значение в меньшую сторону. Далее если нам необходимо, чтобы анимация была зациклена, мы используем оператор вычисляющий остаток от деления, таким образом зацикливая перемещение кадров анимации. В противном случае воспроизводим анимацию только один раз и по окончанию анимации используем для показа последний кадр анимации, пока она не будет изменена (или воспроизведена повторно).
Получив координаты текущего кадра анимации, перемещаем прямоугольник видимости установленной области объекта Sprite на следующий кадр анимации с помощью метода setTextureRect(). Для этого обращаемся к указателю на анимацию и вытягиваем вектор, в котором хранятся координаты и размер кадров анимации, в который передаём индекс текущего кадра анимации. 

Вот и все — класс Аниматор готов к использованию в нашей разработке.

Практическое использование класса Аниматор в разработке “Пчела на работе”

 Создадим в заголовочном классе Engine.h необходимые объекты для анимации.

sf::Time BeeTime;          
sf::Sprite BeeSprite;
Animator BeeAnim = Animator(BeeSprite); 

Обратите внимание на то, что сначала создаётся Sprite, а потом мы создаём объект Animator.

В файле реализации методов класса - Engine.cpp используем методы класса Animator.

void Engine::update(sf::Time const& deltaTime)
{
	BeeAnim.Update(deltaTime);
}

void Engine::draw()
{
	// код
	window->draw(BeeSprite);
	// код
}

Engine::Engine()
{
    // код
	
	auto spriteSize = sf::Vector2i(100, 100);
	auto& idleForward = BeeAnim.CreateAnimation("idleForward", "image/SPRITESHEET.png", sf::seconds(1), true);
	idleForward.AddFrames(sf::Vector2i(0, 0), spriteSize, 6, 1);
	BeeSprite.setPosition(600, 350);

}

В конструкторе класса Engine(), создаём переменною с размерами кадра анимации spriteSize. Далее создаём ссылку на анимацию и присваиваем ей ссылку на созданную анимацию. В параметрах метода CreateAnimation() обозначаем имя анимации, указываем место и название текстуры, устанавливаем время проигрывания анимации 1 секунда и включаем цикличный проигрышь анимации. Через ссылку на анимацию idleForward обращаемся к методу AddFrames() и устанавливаем начальные координаты первого кадра анимации, его размер, общее количество кадров в одной горизонтали загруженной текстуре анимации и количество горизонталей с кадрами анимации. Используя метод setPosition() устанавливаем координаты спрайта. В этих координатах будет проигрываться анимация. Далее запускаем метод Update() и рисуем объект BeeSprite в графическом окне.

Продолжение следует...

Более подробную инструкцию вы можете получить, посмотрев видео «Игра на SFML C++ Пчела на работе часть 3 класс Animator»

Телеграмм канал "Программирование игр С++/С#

Предыдущая тема

Следующая тема

Tags:
Hubs:
Total votes 4: ↑4 and ↓0+4
Comments1

Articles