Спрайтовая анимация на QGraphicsScene Qt

В этой теме 3 ответа, 2 участника, последнее обновление  Васильев Владимир Сергеевич 5 мес., 3 нед. назад.

  • Автор
    Сообщения
  • #3191

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

    Значительно позже я написал свою первую игрушку (описание разработки игры под Andoird), при этом я сначала использовал спрайтовую анимацию, но затем подумал, что предпочтительнее применять стандартные компоненты – я переписал код с использованием QMovie. По приведенной выше ссылки вы можете прочитать как я всем этим пользовался. Однако, после публикации игры на google market я получил несколько негативных отзывов – люди писали, что на большом планшете качество картинки отвратительное.

    Виноват был QMovie, ну и я конечно – ведь я преобразовал анимации в формат .gif, при этом обязательно выполняется сжатие с потерями качества. Я, конечно, пробовал выбрать другой формат – QMovie поддерживал также формат .mng (насколько я понимаю, это как раз то, что нужно – набор кадров с таким же сжатием, как .png). Однако проблемы была как с конвертацией картинок в этот формат, так и с проигрыванием анимации (на телефоне у меня показывался только первый кадр).

    Есть и другие причины не использовать QMovie на графической сцене. Для его отображения нужно использовать QLabel, который является наследником класса QWidget, но при размещении виджетов на сцене значительно падает производительность.

    В связи с этим, я быстро переписал игрушку с использованием .png-анимации. Полученное решение Вы можете увидеть в репозитории игры, однако, я долго не описывал этот вариант, т.к. он не был удобен для повторного использования. Сейчас я переписал его еще раз, и теперь мне кажется, что мои классы можно использовать в других проектах без особых изменений.

    Кадры анимации надо быстро переключать. В игре у нас может для нескольких элементов одновременно проигрываться одна анимация – например если вы создадите 10 одинаковых солдат. Один солдат при этом может начинать шаг, а другой заканчивать его, т.е. они могут проигрывать одну и туже анимацию, но находиться на разном кадре. Было бы здорово загрузить анимацию (.png файл) в единственном экземпляре так, чтобы все элементы игры могли ей пользоваться. При этом каждый элемент должен знать лишь какая анимация ему нужна и номер кадра. Чтобы удовлетворить это требование кадры анимации лучше всего поместить в обычный массив (у него самая низкая трудоемкость произвольного доступа, т.е. операции извлечения элемента по индексу).

    В программе может быть множество анимаций, чтобы не запутаться в них можно применить шаблон проектирования Объектный пул. Пул – это объект, владеющий другими объектами и предоставляющий доступ к ним по идентификатору. В нашем случае внутри пула будут находиться анимации (массивы с кадрами), а идентификатором будет являться имя анимации. Я организовал пул как словарь (QMap) и использовал шаблоны чтобы разделить анимации разных типов – например, в вашей игре анимации персонажей могут находиться в одном пуле, а анимации деревьев – в другом:

    #include <QMap>
    #include <QPixmap>
    #include "singleton.h"
    
    template <typename AnimationEnum>
    class AnimationPool {
    public:
      struct LoadException : public std::exception {
        virtual const char* what() const noexcept {
          return "Animation not loaded\n";
        }
      };
    
      void load(AnimationEnum id, QString filename, int nFrames, int height, int width) {
          QPixmap frames(filename);
          for (int nFrame = 0; nFrame < nFrames; ++nFrame)
              m_animations[id].push_back(cropFrame(frames, nFrames, height, width, nFrame));
      }
    
      QVector<QPixmap*>& get(AnimationEnum id) throw(LoadException) {
          if (false == m_animations.contains(id))
              throw LoadException();
          return m_animations[id];
      }
    
    private:
      QPixmap* cropFrame(const QPixmap frames, const int nFrames,
                         const int height, const int width, const int nFrame) {
          const int frameWidth = frames.width() / nFrames,
                  frameHeight = frames.height();
          return new QPixmap(
              frames.copy(nFrame*frameWidth, 0, frameWidth, frameHeight)
                  .scaled(width, height, Qt::IgnoreAspectRatio, Qt::FastTransformation)
              );
      }
    
      QMap<AnimationEnum, QVector<QPixmap*> > m_animations;
    
      friend class Singleton<AnimationPool<AnimationEnum> >;
    };

    Обратите внимание на использование шаблона проектирования Singleton, он используется тут потому, что доступ к пулу требуется в различных частях программы (нужна глобальная точка доступа), а также, пул должен (соответствующего типа шаблона) существовать в одном экземпляре.

    Для отображения наших элементов на графической сцене нам нужно создать наследника класса QGraphicsItem, но нам ведь нужно отображать кадр анимации (QPixmap), а для этого есть стандартный класс QGraphicsPixmapItem – именно он будет базовым. Кроме того, чтобы использовать механизм сигналов и слотов наш класс наследует QObject:

    #include <QGraphicsPixmapItem>
    #include <QTimer>
    #include <QObject>
    
    #include "animationpool.h"
    #include "animations_enum.h"
    
    class AnimatedGraphicsItem : public QObject, public QGraphicsPixmapItem {
      Q_OBJECT
    public:
      enum Mode { Once, Loop };
      AnimatedGraphicsItem(QObject *parent);
    signals:
      void animationFinished();
    protected:
      void animation(ActorActions animationId, Mode mode, bool randomStartFrame = false, int framerate = DefaultFramerate);
    private slots:
      void on_timerTick();
    private:
      QTimer m_timer;
    
      QVector<QPixmap*> *m_frames;
      int m_nFrames;
      int m_curFrameIndex;
    
      Mode m_mode;
    
      const static int DefaultFramerate = 11;
    };

    Анимированный элемент графической сцены (AnimatedGraphicsItem) имеет два режима воспроизведения анимации – разовый и циклический. В разовом анимация проигрывается лишь один раз, затем элемент скрывается (вызов hide(), см. ниже) и вырабатывается сигнал animationFinished(). Воспроизводить кадры можно начиная с разного номера кадра, по умолчанию с нулевого, но можно задать случайный начальный кадр (это нужно, например если у вас в игре почти одновременно могут быть созданы несколько юнитов – очень некрасиво смотрится если такие юниты идут синхронно). Кроме того, в конструкторе класса можно задать частоту смены кадров.

    Кадры меняются по событиям таймера (именно его настраиваем на определенную частоту), обработка которых выполняется в слоте on_timerTick(). Внутри класса хранится указатель на массив кадров – сами кадры создаются лишь один раз внутри AnimationPool.

    #include "animatedgraphicsitem.h"
    
    AnimatedGraphicsItem::AnimatedGraphicsItem(QObject *parent)
        : QObject(parent) {
        connect(&m_timer, SIGNAL(timeout()), SLOT(on_timerTick()));
    }
    
    void AnimatedGraphicsItem::animation(ActorActions animationId, Mode mode,
                                         bool randomStartFrame, int framerate) {
        m_frames = &ACTOR_ANIMATION_POOL.get(animationId);
        m_mode = mode;
        m_timer.stop();
        m_nFrames = m_frames->size();
    
        if (randomStartFrame)
            m_curFrameIndex = qrand() % m_nFrames;
        else
            m_curFrameIndex = 0;
    
        setPixmap(*(*m_frames)[m_curFrameIndex]);
    
        if (framerate > 0) {
            m_timer.setInterval(1000/framerate);
            m_timer.start();
        }
    }
    
    void AnimatedGraphicsItem::on_timerTick() {
        ++m_curFrameIndex;
        if (m_mode == Loop) {
            if (m_curFrameIndex >= m_nFrames)
                m_curFrameIndex = 0;
            setPixmap(*(*m_frames)[m_curFrameIndex]);
        }
        else {
            if (m_curFrameIndex >= m_nFrames) {
                m_timer.stop();
            hide();
            emit animationFinished();
        }
        else
            setPixmap(*(*m_frames)[m_curFrameIndex]);
        }
    }

    Наследуя класс AnimatedGraphicsItem мы получаем возможность воспроизводить внутри наших элементов .png-анимацию. В качестве примера создадим элемент, управляемый кнопками клавиатуры – мы можем попросить его двигаться в любую из четырех сторон. Во время движения элемент плавно перемещается за счет использования QGraphicsItemAnimation и проигрывает разные наборы спрайтов (на которых видно, что существо бежит влево или вправо).
    qt-sprite-animation-example

    Чтобы сделать такой пример, я сначала создал перечисление со всеми типами анимации (в файле animations_enum.h) для пула и макрос, сокращающий обращение к пулу:

    enum class ActorActions {
        Stay, Up, Down, Left, Right
    };
    #define ACTOR_ANIMATION_POOL Singleton<AnimationPool<ActorActions> >::instance()

    Чтобы работать с этим набором анимации в любой точке программы, достаточно подключить этот файл и обратиться к ACTOR_ANIMATION_POOL, но перед этим в пул надо добавить кадры – делаем это в функции main():

    #include <QApplication>
    #include "view.h"
    #include "animationpool.h"
    #include "animations_enum.h"
    
    int main(int argc, char *argv[]) {
      QApplication a(argc, argv);
    
      ACTOR_ANIMATION_POOL.load(ActorActions::Down, ":/resources/hedgehog_down.png", 11, 246, 280);
      ACTOR_ANIMATION_POOL.load(ActorActions::Up, ":/resources/hedgehog_up.png", 11, 246, 280);
      ACTOR_ANIMATION_POOL.load(ActorActions::Left, ":/resources/hedgehog_left.png", 11, 246, 280);
      ACTOR_ANIMATION_POOL.load(ActorActions::Right, ":/resources/hedgehog_right.png", 11, 246, 280);
      ACTOR_ANIMATION_POOL.load(ActorActions::Stay, ":/resources/hedgehog_stay.png", 31, 246, 280);
    
      View view;
      view.show();
    
      return a.exec();
    }

    Класс View тут это наследник QGraphicsView, для которого перегружена функция обработки событий клавиатуры:

    #include <QGraphicsView>
    #include <QGraphicsScene>
    #include <QGraphicsEllipseItem>
    #include "graphicsactor.h"
    
    class View : public QGraphicsView {
        Q_OBJECT
    public:
        explicit View(QWidget *parent = 0);
    protected slots:
        void keyPressEvent(QKeyEvent *event);
    private:
        QGraphicsScene m_scene;
        GraphicsActor m_actor;
    };

    Класс View лишь получает события клавиатуры, он не должен их обрабатывать, т.к. это обязанность конкретных классов:

    #include "view.h"
    #include <QKeyEvent>
    
    View::View(QWidget *parent)
        : QGraphicsView(parent) {
        setScene(&m_scene);
        m_scene.addItem(&m_actor);
        m_scene.setSceneRect(-100, -100, 200, 200);
        setSceneRect(-100, -100, 100, 100);
        m_actor.setPos(0, 0);
    }
    
    void View::keyPressEvent(QKeyEvent *event) {
        switch(event->key()) {
        case Qt::Key_Left:
            m_actor.processKey(ActorActions::Left);
            break;
        case Qt::Key_Down:
            m_actor.processKey(ActorActions::Down);
            break;
        case Qt::Key_Right:
            m_actor.processKey(ActorActions::Right);
            break;
        case Qt::Key_Up:
            m_actor.processKey(ActorActions::Up);
            break;
        }
    }

    Класс GraphicsActor получает направление движения, соответствующее нажатой клавиши и обрабатывает его включая нужную спрайтовую анимацию и перемещаясь в указанном направлении:

    #include "animatedgraphicsitem.h"
    #include "animations_enum.h"
    
    class QGraphicsItemAnimation;
    
    class GraphicsActor : public AnimatedGraphicsItem {
        Q_OBJECT
    public:
        explicit GraphicsActor(QObject *parent = 0);
    public slots:
        void processKey(ActorActions key);
        void setSprites(ActorActions sprites, bool force = false);
    
        void onAnimationFinished();
    protected:
        const int AnimationPeriodMS = 1000;
        const int SpeedPx = 30;
        ActorActions m_currectAction;
        QGraphicsItemAnimation *m_moveAnimation;
    };

    В конструкторе задается начальное действия персонажа, а также параметры (я использую setScale(), т.к. анимация в .png файле у меня достаточно большая):

    GraphicsActor::GraphicsActor(QObject *parent)
        : AnimatedGraphicsItem(parent),
          m_moveAnimation(nullptr), m_currectAction(ActorActions::Stay) {
        setScale(0.3);
        setSprites(m_currectAction, true);
    }

    Метод setSprites() устанавливает проигрываемый набор кадров, при этом можно потребовать немедленной установки (нужно в конструкторе), без которой анимация будет изменяться только если новая анимация отличается от той, что уже проигрывается:

    void GraphicsActor::setSprites(ActorActions sprites, bool force) {
        if (force || sprites != m_currectAction) {
            animation(sprites, Mode::Loop, true);
        }
    }

    Анимация перемещения будет проигрываться AnimationPeriodMS миллисекунд, после чего в слоте onAnimationFinished() нужно будет запустить ее заново:

    void GraphicsActor::onAnimationFinished() {
        processKey(m_currectAction);
    }

    Основная работа выполняется в функции обработки нажатия клавиши:

    void GraphicsActor::processKey(ActorActions action) {
        // sprites:
        if ((m_currectAction == ActorActions::Left && action == ActorActions::Right) ||
            (m_currectAction == ActorActions::Right && action == ActorActions::Left) ||
            (m_currectAction == ActorActions::Up && action == ActorActions::Down) ||
            (m_currectAction == ActorActions::Down && action == ActorActions::Up)) {
            action = ActorActions::Stay;
        }
        setSprites(action);
    
        // moving
        if (m_moveAnimation) {
            m_moveAnimation->deleteLater();
            m_moveAnimation = nullptr;
        }
        if (action != ActorActions::Stay) {
            QTimeLine *timer = new QTimeLine(AnimationPeriodMS, this);
            m_moveAnimation = new QGraphicsItemAnimation(timer);
    
            connect(timer, SIGNAL(finished()), this, SLOT(onAnimationFinished()));
    
            m_moveAnimation->setItem(this);
            m_moveAnimation->setTimeLine(timer);
    
            switch (action) {
            case ActorActions::Right:
                m_moveAnimation->setPosAt(1.0, pos() + QPointF(SpeedPx, 0));
                break;
            case ActorActions::Left:
                m_moveAnimation->setPosAt(1.0, pos() + QPointF(-SpeedPx, 0));
                break;
            case ActorActions::Up:
                m_moveAnimation->setPosAt(1.0, pos() + QPointF(0, -SpeedPx));
                break;
            case ActorActions::Down:
                m_moveAnimation->setPosAt(1.0, pos() + QPointF(0, SpeedPx));
                break;
            }
            timer->start();
        }
    
        m_currectAction = action;
    }

    Тут объекту устанавливается набор спрайтов и запускается анимация движения .

    Полный исходный код программы вы можете взять в репозитории (на мой взгляд, он вполне пригоден для повторного использования).

  • #3859

    desto
    Участник

    Собрал приведенный пример на qt5.5.
    Анимация движения отыгрывает рывками. Ощущение, что персонаж осуществляет перемещение на величину SpeedPx значительно быстрее, чем положено по AnimationPeriodMS. Остальное время он стоит в финальной точке. Анимация фреймов отыгрывает корректно.

    • #3860

      Ранее не было обнаружено таких проблем. Анимация перемещения – это тема предыдущей статьи: https://pro-prof.com/forums/topic/qt-qgraphicsitemanimation-example. Тут и там используется QGraphicsItemAnimation, но вот пару дней назад мне ребята подсказали, что этот класс в новой версии Qt помечен как deprecated.
      В ближайшее время я посмотрю в чем может быть дело и, вероятно, опишу новую статью без deprecated класса.

    • #3861

      Взял код с репозиотрия, собрал с Qt 5.8 – перемещается плавно (описанных вами проблем нет). Уточните, вы создавали проект сами и вставляли в него код из статьи или взяли готовый проект из репозитория (ссылка в конце статьи)?

Для ответа в этой теме необходимо авторизоваться.