Реализовав в своем проекте Unity общие паттерны проектирования игрового программирования, вы сможете эффективно создавать и поддерживать чистую, организованную и читаемую кодовую базу. Паттерны проектирования не только сокращают время на рефакторинг и тестирование, но и ускоряют процессы внедрения и разработки, способствуя созданию прочного фундамента для развития вашей игры, команды разработчиков и бизнеса.
Воспринимайте паттерны проектирования не как готовые решения, которые можно скопировать и вставить в код, а как дополнительные инструменты, которые при правильном использовании помогут вам создавать более крупные и масштабируемые приложения.
На этой странице мы рассмотрим паттерн State и то, как он может облегчить управление кодовой базой.
Содержание этой статьи основано на бесплатной электронной книге, Повысьте уровень своего кода с помощью паттернов программирования игрв которой рассказывается о хорошо известных паттернах проектирования и приводятся практические примеры их использования в вашем проекте Unity.
Другие статьи из цикла "Шаблоны проектирования игрового программирования Unity" доступны на хабе " Лучшие практики Unity " или по следующим ссылкам:
Представьте, что вы создаете игрового персонажа. В один момент персонаж может стоять на земле. Переместите контроллер, и он начнет бегать или ходить. Нажмите кнопку прыжка, и персонаж подпрыгнет в воздух. Через несколько кадров он приземляется и возвращается в свое нерабочее, стоячее положение.
Интерактивность компьютерных игр требует отслеживания и управления множеством систем, которые меняются во время выполнения. Если вы нарисуете диаграмму, отражающую различные состояния вашего персонажа, у вас получится что-то вроде рисунка выше:
Она напоминает блок-схему, но с некоторыми отличиями:
- Он состоит из нескольких состояний (Idling/Standing, Walking, Running, Jumping и так далее), и только одно текущее состояние активно в данный момент времени.
- Каждое состояние может инициировать переход в другое состояние на основе условий во время выполнения.
- Когда происходит переход, состояние выхода становится новым активным состоянием.
Эта диаграмма иллюстрирует так называемый автомат с конечным состоянием (FSM). В разработке игр одним из типичных вариантов использования FSM является отслеживание внутреннего состояния реквизита или "игрового актера", например, играбельного персонажа. Существует множество вариантов использования FSM в разработке игр, и если у вас есть опыт разработки проектов в Unity, вы, скорее всего, уже использовали FSM в контексте Animation State Machines в Unity.
FSM определяется списком состояний. У него есть начальное состояние с условиями для каждого перехода. В любой момент времени FSM может находиться ровно в одном из конечного числа состояний с возможностью перехода из одного состояния в другое в ответ на внешние входные сигналы, которые приводят к переходу.
Шаблон State, с другой стороны, определяет интерфейс, представляющий состояние, и класс, реализующий этот интерфейс для каждого состояния. Контекст или класс, которому необходимо изменить свое поведение в зависимости от состояния, хранит ссылку на объект текущего состояния. Когда внутреннее состояние контекста меняется, он просто обновляет ссылку на объект состояния, чтобы она указывала на другой объект, который затем меняет поведение контекста.
Шаблон State похож на FSM тем, что также позволяет управлять различными состояниями и переходом между ними. Однако FSM обычно реализуется с помощью оператора switch, в то время как паттерн State определяет интерфейс, представляющий состояние, и класс, реализующий этот интерфейс для каждого состояния.
Паттерн состояния широко используется в разработке игр и может быть эффективным способом управления различными состояниями игры, такими как главное меню, состояние геймплея и состояние завершения игры.
Давайте посмотрим на паттерн состояния в действии на примере следующего раздела.
На Github доступен демонстрационный проект, в котором приведен пример кода из этого раздела.
Упрощенный способ описания базового FSM в коде может выглядеть примерно так, как показано в примере ниже, где используется перечисление и оператор switch.
Сначала вы определяете перечисление PlayerControllerState, состоящее из трех состояний: Холостой ход, прогулка и прыжок.
Затем switch используется в качестве условного оператора в цикле Update, чтобы проверить, в каком состоянии вы сейчас находитесь. В зависимости от состояния вы можете вызвать соответствующие функции для выполнения определенного поведения.
Это может сработать, но сценарий PlayerController может быстро запутаться, особенно если вам нужно сформулировать условия перехода между состояниями. Использование оператора switch для управления состоянием игры с помощью одного скрипта не считается лучшей практикой, поскольку может привести к появлению сложного и трудноразрешимого кода. При увеличении количества состояний и переходов оператор коммутации может стать большим и сложным для понимания.
Кроме того, это усложняет добавление новых состояний или переходов, поскольку необходимо вносить изменения в оператор switch. С другой стороны, паттерн State позволяет создать более модульную и расширяемую конструкцию, что упрощает добавление новых состояний или переходов.
Реализуем паттерн состояния, чтобы реорганизовать логику PlayerController. Этот пример кода также доступен в демонстрационном проекте, размещенном на Github.
Согласно оригинальной версии Gang of Four, паттерн проектирования состояния решает две проблемы:
- Объект должен менять свое поведение при изменении своего внутреннего состояния.
- Поведение в зависимости от состояния определяется независимо. Добавление новых состояний не влияет на поведение существующих.
В предыдущем примере кода UnrefactoredPlayerController класс может отслеживать изменения состояния, но это не удовлетворяет второй проблеме. Вы хотите минимизировать влияние на существующие состояния при добавлении новых. Вместо этого вы можете инкапсулировать состояние в виде объекта.
Представьте, что каждое из состояний в вашем примере структурировано так, как показано на диаграмме выше. Здесь вы вводите соответствующее состояние и зацикливаете каждый кадр до тех пор, пока условие не приведет к выходу потока управления. Другими словами, вы инкапсулируете конкретное состояние с помощью Entry, Update и Exit.
Чтобы реализовать описанный выше паттерн, создайте интерфейс IState. Каждое конкретное состояние в вашей игре будет реализовывать интерфейс, следуя этому соглашению:
- Вступление: Эта логика выполняется при первом входе в состояние.
- Обновление: Эта логика выполняется каждый кадр (иногда ее называют Execute или Tick). Вы можете дополнительно сегментировать метод Update, как это делает MonoBehaviour, используя FixedUpdate для физики, LateUpdate и так далее. Любая функциональность в Update работает каждый кадр до тех пор, пока не будет обнаружено условие, вызывающее изменение состояния.
- Выход: Код здесь выполняется перед выходом из состояния и переходом в новое состояние.
Для каждого состояния нужно создать класс, реализующий IState. В проекте примера для WalkState, IdleState и JumpState был создан отдельный класс.
Другой класс, StateMachine.cs, будет управлять тем, как поток управления входит и выходит из состояний. С тремя примерами состояний машина состояний может выглядеть так, как показано в примере кода ниже.
Чтобы следовать шаблону, машина состояний ссылается на публичный объект для каждого состояния, которым она управляет (в данном случае, walkState, jumpState и idleState). Поскольку машина состояний не наследуется от MonoBehaviour, используйте конструктор для установки каждого экземпляра.
Вы можете передать конструктору любые необходимые параметры. В примере проекта в каждом состоянии ссылается на PlayerController. Затем вы используете это для обновления каждого состояния в каждом кадре (см. пример IdleState ниже).
Обратите внимание на следующее о концепции машины состояний:
- Атрибут Serializable позволяет отображать StateMachine.cs (и его открытые поля) в Инспекторе. Другой MonoBehaviour (например, PlayerController или EnemyController) может использовать эту машину состояний в качестве поля.
- Свойство CurrentState доступно только для чтения. В самом файле StateMachine.cs это поле явно не задается. Внешний объект, например PlayerController, может вызвать метод Initialize, чтобы установить состояние по умолчанию.
- Каждый объект состояния определяет свои условия для вызова метода TransitionTo для изменения текущего активного состояния. Вы можете передать все необходимые зависимости (включая саму машину состояний) каждому состоянию при настройке экземпляра StateMachine.
В примере проекта PlayerController уже содержит ссылку на StateMachine, поэтому вы передаете только один параметр игрока.
Каждый объект состояния будет управлять своей собственной внутренней логикой, и вы можете создать столько состояний, сколько необходимо для описания вашего игрового объекта или компонента. Каждый из них получает свой собственный класс, реализующий IState. В соответствии с принципами SOLID, добавление новых состояний оказывает минимальное влияние на все ранее созданные состояния.
Вот пример состояния IdleState.
Как и в сценарии StateMachine.cs, конструктор используется для передачи объекта PlayerController. Этот плеер содержит ссылку на машину состояний и все остальное, необходимое для логики обновления. IdleState отслеживает состояние скорости или прыжка контроллера персонажа и затем вызывает метод TransitionTo машины состояний соответствующим образом.
Просмотрите пример проекта для реализации WalkState и JumpState. Вместо того чтобы иметь один большой класс, переключающий поведение, каждое состояние имеет свою собственную логику обновления, что позволяет им функционировать независимо друг от друга.
Паттерн состояния поможет вам придерживаться принципов SOLID при настройке внутренней логики объекта. Каждое состояние относительно невелико и отслеживает только условия перехода в другое состояние. В соответствии с принципом "открыто-закрыто" вы можете добавлять новые состояния, не затрагивая существующие, и избегать громоздких операторов switch или if в одном монолитном скрипте.
Вы также можете расширить его функциональность, чтобы передавать изменения состояния внешним объектам. Возможно, вы захотите добавить события (см. шаблон наблюдателя). Наличие события при входе или выходе из состояния может уведомить соответствующих слушателей и заставить их реагировать во время выполнения.
С другой стороны, если вам нужно отслеживать всего несколько штатов, дополнительная структура может оказаться излишней. Этот шаблон может иметь смысл только в том случае, если вы ожидаете, что ваши штаты вырастут до определенной сложности. Как и в случае с любым другим паттерном дизайна, вам нужно будет оценить все плюсы и минусы, исходя из потребностей вашей конкретной игры.
Более продвинутые ресурсы по программированию в Unity
Электронная книга Повысьте уровень своего кода с помощью паттернов программирования игрсодержит больше примеров использования паттернов проектирования в Unity.
Все передовые технические электронные книги и статьи Unity доступны на хабе лучших практик. Электронные книги также доступны на странице " Передовые методы" в документации.