Сегодня мы наконец займемся воплощением идеи менеджера состояний в реальный программный код (но закончим в другой раз). Я уже довольно много рассказал об основных принципах, заложенных в эту систему, теперь нужно адаптировать их под программные средства выбранного языка программирования (С++).
На всякий случай сразу предупрежу – ниже изложенная информация ориентирована явно не на новичков в программировании. Но вы ведь помните, что читателям этого сайта рекомендуется иметь за плечами по крайней мере одну доведенную до конца игру, или хотя бы средний опыт в программировании?
Перед прочтением я также рекомендую освежить в памяти содержание следующих постов: Проектирование на уровне приложений и движков, Менеджер игровых состояний и конечные автоматы. Также по ходу пьесы, вероятно, придется довольно много читать и гуглить.
Теперь, когда я вас достаточно запугал – перейдем наконец к теме, обозначенной в заголовке этого поста.
Базовые принципы
Для начала четко сформулирую те идеи, которые я закладываю в свой менеджер состояний. В дальнейшем многие проектные решения будут приниматься именно на основе того или иного принципа (и это зачастую будет приводить к появлению в проекте и коде достаточно нетривиальных структур).
- В каждый конкретный момент игра находится ровно в одном состоянии. По сути каждое состояние представляет собой «приложение внутри приложения». Когда игра переходит в другое состояние, ее поведение сильно меняется.
- Состояния ничего не знают друг о друге. Их задачей является только обеспечение надлежащего функционирования игрового приложения в конкретный момент времени.
- Поскольку состояниям друг о друге ничего не известно, то они не могут (и не должны) управлять логикой перехода между собой. Эту задачу берет на себя менеджер игровых состояний.
- Менеджер состояний работает по принципу конечного автомата. Переход в другое состояние происходит тогда, когда менеджер получает управляющий сигнал. При этом новое состояние определяется только типом текущего состояния и типом управляющего сигнала.
- Состояние является основой логики работы игрового приложения. Все остальные средства управления логикой (например, игровой цикл) вызываются внутри состояния, при этом их использование или неиспользование целиком находится в компетенции данного состояния.
- Менеджер состояний в зависимости от ситуации может не только выполнять действие «удалить текущее состояние, создать новое состояние, сделать его текущим», но и помещать текущее состояние в стек, не удаляя его, и затем делать текущим новое состояние; либо после удаления текущего состояния извлекать из головы стека ранее помещенное туда состояние и делать его текущим. При этом состояние извлекается из стека в том же виде, в котором было в него помещено (затем оно продолжает работу с того места, на котором остановилось).
Обоснование этих принципов приводилось в более ранних постах, здесь я не буду его повторять.
Замечу еще, что хотя изложенные здесь идеи выглядят достаточно естественно, их реализация в коде на C++ зачастую будет очень нетривиальной. Возможно, гораздо естественнее было бы сделать это на Smalltalk, но – имеем то, что имеем.
Основа архитектуры менеджера состояний
Теперь рассмотрим, как именно мы будем выражать обозначенные выше понятия в терминах языка C++.
Очевидно, что у нас будет по крайней мере три класса – абстрактный GameState (конкретные состояний будут его наследниками), GameStateManager (уместно сделать его синглтоном – кстати, это довольно редкая ситуация) и StateEvent.
Касательно последнего сразу замечу, что было бы удобно передавать вместе с событием некоторую дополнительную информацию, помогающую в настройке нового состояния (но никак не учитывающуюся при работе конечного автомата в менеджере состояний!).
Впрочем, это все достаточно очевидно, так что перейдем к рассмотрению более интересных вещей. Для начала займемся упомянутым выше конечным автоматом и разберемся в его работе. Возможность манипулирования стеком до поры до времени оставим в стороне.
На первый взгляд, все довольно очевидно – у состояний и событий есть типы, выражающиеся перечислениями StateType и EventType соответственно, в интерфейсах этих классов есть виртуальные функции, возвращающие тип конкретного класса. На основании этих данных конечный автомат, используя таблицу переходов, легко может выдать… ой!
Единственное, что мы можем пока получить – это тип следующего состояния. Но нам-то нужно создать объект определенного типа!
Именно эту задачу можно успешно решить с помощью паттерна проектирования Factory (Фабрика). Он позволяет по переданному ему идентификатору типа создать новый объект, принадлежащий соответствующему классу. Замечу, что описанный Бандой четырех (Gang of Four, GoF) паттерн Factory Method (Фабричный метод) является в лучшем случае составным элементом необходимой мне фабрики, поэтому он не подходит для решения данной задачи.
Мне известны два варианта реализации нужного паттерна. Первый из них приводится в книге Александреску «Современное проектирование на C++» в главе 8, вместе с рядом ценных соображений по построению фабрик. Второй описан в статьях «Ставим объекты на поток, паттерн фабрика объектов» и «Factory, она же — фабрика». Первая статья описывает тему более полно, вторая – более лаконична и схематична.
Основное различие двух этих вариантов состоит в том, откуда берутся функции, создающие конкретный объект. В варианте, предложенном Александреску, применяются как раз фабричные методы, которые нужно для каждого типа прописывать самостоятельно. Во втором случае соответствующие функции генерируются автоматически на основе шаблонного метода.
Преимущество первого подхода в том, что мы можем вручную настраивать поведение каждого фабричного метода, недостаток – необходимость каждый раз вручную прописывать дополнительный код в нагрузку к каждому целевому классу (мало нам идентификатора типа в соответствующем перечислении и вызова метода регистрации типа в фабрике!).
Преимущества и недостатки второго подхода зеркальны – нет необходимости писать лишний код, но зато и сделать что-то более сложное, чем вызов оператора new (чего нам бы хотелось – см. ниже) представляется очень нетривиальной задачей.
Вернемся теперь к реализации нашего менеджера состояний. Теперь по совокупности кода типа и кода события мы можем с помощью фабрики создавать новый объект нужного нам состояния. Однако, как мы помним, мы также хотели бы с помощью дополнительной информации, содержащейся в подклассах конкретных событий, настраивать новое состояние. Стало быть, нам необходимо передавать эту информацию конструктору класса состояния.
И вот тут-то проявляется одно из отличий двух вариантов реализации фабрики. Имея на руках написанные вручную фабричные методы, мы можем в каждом из них производить восходящее приведение с помощью dynamic_cast к нужному подклассу событий, извлекать из него необходимые данные и в явном виде передавать их конструктору состояния.
Если же фабричный метод генерируется на основе шаблона, то лучшее, что мы можем сделать – организовать класс StateEvent таким образом, чтобы он содержал только идентификатор своего типа и указатель на класс StateEventData. В этом случае полиморфным будет только тип с дополнительной информацией, а у событий не будет подтипов. Тогда мы сможем передавать в конструктор состояния указатель на StateEventData, который будет приводиться к нужному типу уже внутри тела конструктора.
Есть и другой вариант решения этой задачи в рамках второго подхода к реализации фабрики – мы можем в фабричном методе приводить тип события к нужному подклассу и передавать конструктору указатель на конкретный тип. Однако, сомнительное удобство (мы все равно передаем новому состоянию не те данные, которые ему реально нужны, а только упаковку для них) требует вынести информацию о подтипе, к которому нужно приводить данное событие, за рамки компетенции подходящих классов. Нам пришлось бы указывать этот тип во внешнем коде в качестве еще одного шаблонного параметра, что чревато дополнительными ошибками.
Итак, у нас есть две альтернативы – либо вручную прописывать фабричные методы для каждого класса состояний, но при этом соблюдать естественную семантику (в конструктор передается только та информация, которая ему реально нужна, при этом возможные ошибки преобразования случатся на более раннем этапе); либо же переложить работу по генерации нужного кода на компилятор, но при этом делая код менее естественным и отдаляя время проявления возможной ошибки.
Ни тот, ни другой минус меня не радует, поэтому я пока не определился, какой вариант лучше выбрать. Возможно, существует способ обойти их оба. Если у вас есть мысли на этот счет – я был бы рад увидеть их в комментариях к данному посту.
Прежде чем закончить с этим пунктом, замечу, что работа со стеком состояний реализуется достаточно просто – нужно только добавить в событие информацию о том, каким образом определить следующее состояние и что делать с текущим. Возможны три варианта: старое состояние удалить, новое определяется по правилам простого ДКА; старое состояние поместить в стек, новое найти через ДКА; если стек не пуст, то удалить текущее, новым сделать состояние с вершины стека, если пуст – ничего не делать.
Могут быть также полезны другие опции (например, после установки нового состояния также очистить весь стек). Впрочем, если при наличии всего трех альтернатив можно было особенно не задумываться над реализацией надстройки, предназначенной для работы со стеком, и смело лепить короткий switch, то при увеличении количества вариантов появляются мысли о применении чего-то наподобие фабрики, стратегий или еще чего-то зубодробительного. Впрочем, этот вопрос я пока сочту неактуальным и буду изобретать очередной велосипед только если в этом возникнет необходимость.
Основы работы состояний
Теперь, когда проблема переключения между состояниями более-менее решена, перейдем к вопросу о том, как именно применять эти состояния для реализации игрового функционала.
Начнем с того, что принцип, согласно которому игровое состояние первично, а главный цикл вторичен, приводит нас к необходимости как-то реализовывать второй внутри первого. Если бы приоритеты были расставлены наоборот, было бы значительно проще – где-то мы запускаем цикл, и на каждой итерации выполняем функцию Update текущего состояния.
Однако нам придется заходить с другого конца. Самый очевидный вариант – реализовать игровой цикл внутри игрового состояния. А именно – в функции Run запускать отдельный поток, в котором будет крутиться цикл, постоянно проверяющий текущее время и в нужные моменты выполняющий что-то полезное (либо можно вместо цикла и явной проверки времени сделать таймер и выполнять полезные действия по событию срабатывания таймера – хотя внутри него будет то же самое).
Когда нужно будет прервать выполнение главного цикла и поместить состояние в стек, придется вызывать функцию Pause, которая сообщит главному циклу, что пока не нужно делать ничего важного. Цикл, конечно, продолжит крутиться, но будет делать это вхолостую (а вот таймер можно просто уничтожить, а когда понадобится – создать новый).
Кажется, это не очень элегантное решение. Поэтому я собираюсь использовать другой метод. Суть его состоит в том, что в лучших традициях паттерна State мы отождествляем класс текущего состояния с эффективным классом игрового приложения. Как только мы переключаемся на другое состояние – приложение как будто бы меняет свой класс.
Это означает, что приложение в той или иной степени делегирует управление текущему состоянию и позволяет ему руководить ходом работы и реакцией на внешние раздражители. Развивая эту идею, я прихожу к следующей концепции. Состояние связывается с окружающим миром посредством событий, в чем-то сходных с теми, которые получает менеджер. При этом такие события являются единственным способом коммуникации состояния с внешним миром.
Когда состояние становится активным, менеджер соединяет его с системами ввода с помощью связей наподобие сигнало-слотовых, реализованных в Qt и Boost. Когда же состояние перестает быть активным – эти связи разрушаются, и состояние перестает реагировать на происходящее в окружающем мире.
Системами ввода являются, конечно, мышка и клавиатура (от них приходят сообщения о перемещении курсора и нажатии/отпускании клавиш и кнопок), но также и таймер. Учитывая это, неактивное игровое состояние перестает что-либо совершать вовсе, поскольку, будучи лишено информации извне, даже не знает, что время идет.
При этом, поскольку каждое событие таймера интерпретируется как простой тик, на них можно строить собственное время игрового состояния. Игровые события должны происходить только когда главное состояние активно. Если мы на несколько минут зашли в меню – приложение не будет по возвращении назад пытаться просчитать все, что произошло за это время (по показаниям системных часов). С точки зрения часов, идущих только тогда, когда состояние получает сигналы таймера, прошло всего несколько миллисекунд – один тик. А это именно то, что и должно быть.
Отмечу еще, что для реализации этой идеи действительно можно было бы обойтись сигналами и слотами Qt. Однако, я планирую сделать код как можно более переносимым и не зависящим от того или иного фреймворка. Поэтому все, что выходит за рамки стандартной библиотеки, должно быть закопано как можно глубже, на самый низкий уровень абстракции. Кроме того, реализация своего аналога сигнало-слотовых соединений – задача сама по себе интересная.
Гвозди микроскопом?
Возможно, приведенные здесь проектные решения являются более сложными, чем это необходимо для решения поставленной задачи. Наверное, можно было бы использовать более топорные и понятные решения, не потеряв при этом в функциональности, но выиграв в простоте поддержки кода.
Однако, поскольку этот проект является во многом исследовательски-учебным, то я считаю гибкость и расширяемость решений более приоритетной чертой, чем простота реализации и поддержки. Конечно, перегибать палку все равно не стоит, но если есть возможность написать код «на вырост», который можно будет без существенных модификаций применить при решении более сложных задач, и не сломать себе при этом мозг, то я намерен этой возможностью воспользоваться.
Подводя промежуточные итоги
Итак, концепция менеджера состояний оказалась недостаточно проработана в прошлых постах, чтобы можно было легко реализовать ее в коде. Именно поэтому мне пришлось углубленно рассмотреть вопрос проектирования менеджера состояний. На очереди – изучение механизма обмена сообщениями и написание-таки прототипа упомянутого менеджера.