Здравствуйте, дорогие читатели!

У нас активно расходится третий доптираж крайне успешной книги «Изучаем C++ через программирование игр». Поэтому сегодня вашему вниманию предлагается перевод интересной статьи на одну из узких тем, связанных с программированием игр на C++. Также просим вас поучаствовать в опросе

Я впервые приобрел впечатление о различных состояниях игры много лет назад, когда смотрел одну демку. Это было не «превью готовящейся игры», а нечто олдскульное, «с сайта scene.org». Так или иначе, подобные демки совершенно незаметно переходили от одного эффекта к другому. От каких-нибудь двухмерных вихрей игра могла переключиться сразу на сложный рендеринг трехмерной сцены. Помню, мне казалось, что для реализации этих эффектов требуется сразу несколько отдельных программ.

Множественные состояния важны не только в демках, но и в любых играх. Любая игра начинается с заставки, затем открывает определенное меню, после чего начинается геймплей. Когда вы будете окончательно побеждены, игра переходит в состояние «game over», за которым обычно следует возврат в меню. В большинстве игр можно одновременно находиться в двух и более состояниях. Например, во время геймплея обычно можно открыть меню.

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

Что такое состояние?

Как было указано выше, состояние — это практически отдельная программа в составе игры. В каждом состоянии события обрабатываются по-своему, на экране отрисовываются свои элементы. В каждом состоянии обрабатываются собственные события, обновляется игровой мир, и на экране рисуется следующий кадр. Вот мы и определили три метода, которые должны содержаться в нашем классе состояния.

Кроме того, игровое состояние должно «уметь» загружать графику и само себя инициализировать, а также избавляться от ненужных ресурсов, когда задача будет решена. Случается, когда мы хотим поставить состояние на паузу и возобновить его позднее. Например, мы хотим ненадолго приостановить игру, чтобы вывести меню. Пока у нас вырисовывается такой класс игрового состояния:

class CGameState
{
public:
  void Init();
  void Cleanup();

  void Pause();
  void Resume();

  void HandleEvents();
  void Update();
  void Draw();
};


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

Менеджер состояний

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

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

Та часть движка, что связана с менеджером состояний, в сущности, очень проста. Чтобы одни состояния могли существовать поверх других, нужно расположить их в виде стека. Я собираюсь реализовать такой стек при помощи вектора из STL. Кроме того, мне понадобятся методы для смены состояний, а также для перемещения их по стеку вверх-вниз.

Итак, класс игрового движка приобретает следующий вид:

class CGameEngine
{
public:
  void Init();
  void Cleanup();

  void ChangeState(CGameState* state);
  void PushState(CGameState* state);
  void PopState();

  void HandleEvents();
  void Update();
  void Draw();

  bool Running() { return m_running; }
  void Quit() { m_running = false; }

private:
  // стек состояний
  vector<CGameState*> states;

  bool m_running;
};


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

Последний вопрос — как переходить между состояниями. Как движок узнает, когда переходить из одного состояния в другое? Никак. О необходимости перехода в следующее состояние узнает лишь текущее состояние. Итак, мы вновь вернемся к классу игровых состояний и добавим туда функцию для перехода между ними.

В данном случае мы будем создавать абстрактный базовый класс, и сделаем большинство его членов чистыми виртуальными функциями. Так гарантируется, что унаследованный класс будет их реализовывать. С учетом всех этих изменений готовый класс игровых состояний примет такой вид:

class CGameState
{
public:
  virtual void Init() = 0;
  virtual void Cleanup() = 0;

  virtual void Pause() = 0;
  virtual void Resume() = 0;

  virtual void HandleEvents(CGameEngine* game) = 0;
  virtual void Update(CGameEngine* game) = 0;
  virtual void Draw(CGameEngine* game) = 0;

  void ChangeState(CGameEngine* game,
                   CGameState* state) {
    game->ChangeState(state);
  }

  protected: CGameState() { }
};


Теперь добавлять состояния к нашей игре предельно просто: наследуем базовый класс и определяем семь чистых виртуальных функций. Поскольку нам в любом случае потребуется не более одного экземпляра любого конкретного состояния, давайте реализуем их в виде одиночек. Если вы не знакомы с паттерном «одиночка», поясню: он просто позволяет убедиться, что объект существует ровно в одном экземпляре. Для этого конструктор делается защищенным, а затем делается функция, возвращающая указатель на статический экземпляр данного класса.

Чтобы вы могли представить, насколько этот метод может упростить всю игру, обратите внимание на следующий листинг, где находится весь файл main.cpp
:

#include "gameengine.h"
#include "introstate.h"

int main ( int argc, char *argv[] )
{
  CGameEngine game;

  // инициализация движка
  game.Init( "Engine Test v1.0" );

  // загрузка заставки
  game.ChangeState( CIntroState::Instance() );

  // основной цикл
  while ( game.Running() )
  {
    game.HandleEvents();
    game.Update();
    game.Draw();
  }

  // очистка движка
  game.Cleanup();
  return 0;
}


Файлы

В этом примере описаны три состояния: заставка, выступающая на черном фоне, геймплей и игровое меню. На время работы с меню геймплей приостанавливается, а после закрытия меню – возобновляется. Каждому состоянию соответствует простое фоновое изображение.

stateman.zip – Исходный код, графика и файлы проекта для Visual C++
stateman.tar.gz — Исходный код, графика и файлы проекта для Linux.

В коде примеров используется SDL. Если вы не знакомы с SDL, почитайте мой туториал Getting Started with SDL. Если у вас на компьютере не установлена SDL, то вы не сможете скомпилировать и запустить этот пример.

Ресурсы

Если вы только начинаете изучать C++, то определенно должны познакомиться с книгой «Изучаем C++ через программирование игр». Это замечательное введение в язык программирования C++, в качестве примеров автор использует простые игры. Программистам среднего уровня я рекомендую C++ For Game Programmers. Эта книга поможет вам углубить знания C++. Наконец, чтобы как следует усвоить паттерны, читайте книгу «Паттерны проектирования» под авторством «Банды четырех».
Нужна ли книга «C++ For Game Programmers»

Проголосовало 196 человек. Воздержалось 63 человека.

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

Комментарии (17)


  1. BaRoN
    29.01.2016 16:22
    +1

    А готовых конечных автоматов в каком-нибудь Boost совсем нету?


    1. semenyakinVS
      29.01.2016 16:46
      +2

      Кстати, есть. Я когда-то смотрел — там есть вообще упоротая библиотека для супербыстрых машин состояний на темплейтной магии… Вот ссылочка с примером. Если честно, сам таким я бы пользоваться заопасался (правда, читал про неё пока мало).


      1. BaRoN
        29.01.2016 17:02
        +3

        Я вообще C++ опасаюсь пользоваться =). Но вроде выглядит нормально. Ну, во всяком случае примерно так же, как и куча другого непонятного Boost кода.


        1. semenyakinVS
          29.01.2016 17:16
          +1

          Оно нормально до тех пор, пока не начинается иерархичность машин состояний. Там дальше про иерархические машины рассказ идёт — и такая жажа начинается, что ховайся.

          Вообще, меня постоянно озадачивает тот факт, что машину состояний не выносят в отдельный класс, объекты которого встраивают туда, где нужно эти состояния хранить (ну, а коллбеки байндят к вызовам класса через те же делегаты). Сам пишу игровой движок, пока застрял на уровне рендера, но когда доберусь до логики — обязательно на слои попробую разбить в этом смысле, чтобы понять в чём подвох.


          1. BaRoN
            29.01.2016 17:28
            +1

            Ну тут автор объясняет своё решение простотой.
            У нас модель иерархическая и сложная, поэтому дерево лежит вообще отдельно. И каждое состояние — отдельный класс.
            Зато бонусом идёт то, что на этой логике было сделано уже 4 проекта (в т.ч. не совсем похожие друг на друга).

            Впрочем, в бэкэнде с этим проще — не надо никак отображать изменения, так что легко делать изолированный от всего конечный автомат.


  1. gbg
    30.01.2016 12:11
    +2

    Наличие методов Init() и Cleanup() определленно указывает, что автор плевать хотел на RAII.

    -Получение экземпляра есть инициализация, разрушение — деинициализация!
    -Не, не слышал…


    1. dendron
      30.01.2016 17:17
      +1

      В данном случае, скорее всего, имелось в виду выделение и освобождение ресурсов, что зачастую не принято делать в конструкторе/деструкторе. Лично я это поддерживаю.

      Кстати, Вы случайно не с linux.org.ru? Манера общения похожая.


      1. gbg
        30.01.2016 22:04
        +1

        Выделение и освобождение ресурсов как раз следует делать в конструкторе и деструкторе. C++, особенно последних версий, имеет прекрасные языковые средства, позволяющие писать классы, устойчивые к необдуманному использованию.

        В случае, когда экземпляру требуется дополнительная инициализация после создания, возникают дополнительные соглашения
        [Чтобы использовать мой класс, надо вызвать последовательно Init3() и Init1(), но ни в коем случае не Init2(), она там для другого, понятненько?] между разработчиком класса и теми программистами, которые этот класс использует. Причем эти соглашения компилятор проверить не в состоянии.

        Класс всегда должен быть в корректном состоянии, готовый к тому, что из него что-то вызовут. Если ресурсы получает не конструктор, этого добиться нельзя.


        1. dendron
          31.01.2016 03:29
          +1

          Что если Init() — виртуальный метод?


          1. gbg
            31.01.2016 09:24
            +1

            Отдельного метода инициализации не должно существовать — все должен делать конструктор.

            В случае наличия нескольких конструкторов, возможно либо использовать возможности C++11 (вызов конструктора из конструктора), либо сделать закрытый метод, в который убрать общую инициализацию.

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

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


        1. midday
          31.01.2016 16:03
          +2

          Я обхожу это путем засовывания конструктора в приват/протектед, а наружу либо фабрика, либо фабричные методы и т.п.


          1. gbg
            31.01.2016 16:05
            +1

            Ну, главная идея вами при этом сохраняется — продукция ваших фабрик — полностью готовые экземпляры, не так ли?

            Фабрика чуть лучше конструктора, потому что она может иметь более явное и ясное название, нежели весьма безликий конструктор.


    1. Lertmind
      31.01.2016 00:23
      +1

      Посмотрите код внимательней, Init() и Cleanup() пришлось ввести потому что классы состояний — Одиночки, там один экземпляр класса и он статический, если так не делать, то они сразу будут занимать место в памяти. С другой стороны, если убрать Одиночек, то всю логику инициализации и удаления можно перенести в конструктор/деструктор.


      1. gbg
        31.01.2016 00:27
        +1

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

        P.S. Я видел одиночку, разделяемого между плагинами и основным приложением. Это. Было. Чудовище.

        Мое мнение — долой одиночек. Одних только танцев вокруг iostreams + dll достаточно, чтобы забыть про одиночек раз и навсегда.


        1. semenyakinVS
          31.01.2016 02:21
          +2

          Я видел одиночку, разделяемого между плагинами и основным приложением. Это. Было. Чудовище


          А в чём именно была проблема, если не секрет? Как я понимаю, доступ происходил через функцию?.. Там ведь главное память не трогать из других dll/из exe-файла — чтобы модули не лезли в чужие кучи. И что там с iostreams + dll плохого?.. Видимо, с хэндлами потоков данных какие-то чудеса, или нет?


          1. gbg
            31.01.2016 09:25

            Я отвечу уклончиво — все перечисленные вами проблемы там были. И другие тоже были. На этом, данную тему предлагаю свернуть как оффтопик.


  1. Lertmind
    30.01.2016 18:31
    +2

    Упомяну главу про паттерн Состояние из известнейшей книги по игровым паттернам Game Programming Patterns. Там хорошо описаны плюсы и минусы, архитектурные решения. Использованный здесь стек состояний там называется Pushdown Automata. В этой главе он специально не упоминает паттерн Одиночка, потому что этот паттерн считается переоценённым, вместо этого предлагает, как вариант, хранить экземпляры конкретных состояний просто как static поле в базовом классе состояния.