Привет! Меня зовут Андрей Шило, я android-разработчик в компании FINCH. Сегодня я расскажу вам о том какие ошибки не стоит допускать при написании даже самой простой игры и чем крут архитектурный подход Entity Component System(ECS).
Первый раз всегда больно
У нас есть один веселый проект для крупного медиахолдинга, который представляет из себя нестандартную соц. сеть с постами, комментами, лайками и видео. Однажды, нам дали таск — внедрить игровую механику в качестве промо-акции. Игра выглядела как простые мини-гонки, где при тапе слева/справа машина перемещалась на полосу влево/вправо. Так, уклоняясь от препятствий и собирая бустеры нужно было добраться до финиша, всего у игрока было три жизни.
Игра должна была быть реализована прямо внутри приложения, естественно, на отдельном экране. В качестве движка мы выбрали безоговорочно LibGDX, так как кодить игру можно на kotiln, а отладку делать на десктопе, запуская игру как java-приложение. При этом у нас не было людей, которые знали какие-то другие движки, которые можно внедрить в приложение (если знаете, то поделитесь).
Выглядит игра так:
Так как игра по изначальному ТЗ показалась простой, то мы не стали копаться в архитектурных подходах. К тому же сами промо-акции быстро проходят — в среднем, одна акция занимает месяц-полтора. Соответственно, позже, код игры просто выпилится и будет не нужен до следующей подобной промки, при условии если кто-то захочет повторить что-то подобное.
Все вышеописанные факторы и любимые, подгоняющие менеджеры подтолкнули нас к написанию механик игры без какой-либо архитектуры.
Описание получившейся игры
Основная часть кода была собрана в классах: MyGdxGame: Game, GameScreen: Screen и TrafficGame: Actor.
MyGdxGame — является входной точкой при старте игры, здесь в конструктор передаются параметры в виде строк. Здесь также создается GameScreen и параметры игры, которые передаются дальше в этот класс, но уже в другом виде.
GameScreen — создает актера игры TrafficGame, добавляет его на сцену, передает ему уже упомянутые параметры, а также «слушает» нажатия пользователя по экрану и вызывает соответствующие методы актера TrafficGame.
TrafficGame — основной актер сцены в котором происходит все движение игры: отрисовка и логика работы.
Хоть использование scene2d дает возможность выстраивать деревья вложенности актеров, это не лучшее архитектурное решение. Впрочем, для реализации UI/UX игры(на LibGDX), scene2d — отличный вариант.
В нашем случае в TrafficGame есть огромный сборник смешанных инстансов и всякого рода флажков поведения, которые разрешались в методах с большими when конструкциями. Пример:
var isGameActive: Boolean = true
set(value) {
backgroundActor?.isGameActive = value
boostersMap.values.filterNotNull().forEach { it.isGameActive = value }
obstaclesMap.values.filterNotNull().forEach { it.isGameActive = value }
finishActor.isGameActive = value
field = value
}
private var backgroundActoolbarActor
private val pauseButtonActor: PauseButtonActor
private val finishActor: FinishActor
private var isQuizWon = falser: BackgroundActor? = null
private var playerCarActor: PlayerCarActor
private var toolbarActor: To
private var pointsTime = 0f
private var totalTimeElapsed = 0
private val timeToFinishTheGame = 50
private var lastQuizBoosterTime = 0.0f
private var lastBoosterTime = 0.0f
private val boostersMap = hashMapOf<Long?, BoosterActor?>()
private var boosterSpawnedCount = 0
private var totalBoostersEatenCount = 0
private val boosterLimit = 20
private var lastBoosterYPos = 0.0f
private var toGenerateBooster = false
private var lastObstacleTime = 0.0f
private var obstaclesMap = hashMapOf<Long?, ObstacleActor?>()
Естественно так писать не стоит. Но в защиту скажу, что у нас так получилось потому что:
- Стартовать нужно сейчас, ну, а финальное ТЗ с дизайном покажем потом. Классика
- Архитектуры которые уже знакомы(MVP/MVC/MVVM и т.д.) не подходят для реализации игрового процесса, так как они предназначены чисто для пользовательского интерфейса, в игре же все происходит в реальном времени.
- Изначально игра показалась простой, однако на деле требовала много кода, учитывающего огромное количество нюансов основная масса которых всплывала уже во время написания игры.
Помимо всех перечисленных трудностей существует еще одна распространенная проблема с наследованием. Если делать игру сложнее, например платформер, то появляется вопрос — «Как распространить переиспользуемый код между объектами игры?». Чаще всего выбирается вариант с наследованием, где повторно используемый код помещается в родительские классы. Но это решение плодит много проблем если появляются условия, не вписывающееся в дерево наследования:
И обычно решают такие проблемы, переписывая структуру дерева наследования с нуля (ну в этот раз уж точно будет лучше), либо костылями ломают родительские классы.
ECS — наше все
Совсем другая история — наша вторая промо-игра. Она была подобием Flappy Bird, но с отличиями: персонаж управлялся голосом, а потолок и пол это не были препятствиями — по ним можно было скользить.
Пример игрового процесса и для сравнения процесс игры в Flappy Bird:
Для большей наглядности в примере камера удалена чтобы увидеть закулисье игры. Пол и потолок это квадратные блоки которые доходя до края переставляются в начало, а препятствия генерируются по заданной схеме которая приходит с бэка. Дизайн игры выбирали заказчики, поэтому не удивляйтесь.
Мне нравится разработка игр под мобильные устройства и в нерабочее время, ради эксперимента, я исследую игровые паттерны и все остальное что связано с разработкой игр. Я прочитал книгу по паттернам игрового проектирования, но так и не понимал какой должна быть та самая True Architecture геймплейной логики, пока не наткнулся на ECS.
Entity Component System — паттерн проектирования чаще всего используемый в разработках игр. Основной замысел паттерна — композиция вместо наследования. Композиция позволяет смешивать различные механики на игровых объектах, это, в перспективе, позволяет делегировать настройку свойств объекта геймдизайнеру, например посредством написанного конструктора. Так как я уже был знаком с этим паттерном то мы решили применить его во второй игре.
Рассмотрим составляющие паттерна:
- Component — объекты с простой структурой данных не содержащие никакой логики, либо выступающие в качестве ярлыка. Компоненты разделены по назначению и определяют все свойства игровых объектов. Скорость, позиция, текстура, тело и т.д. и т.п. все это описывается в компонентах и далее добавляется к игровым объектам.
class VelocityComponent: Component { val velocity = Vector2() }
- Entity — игровые объекты: препятствия/бустеры/управляемый герой и даже бэкграунд. Не имеют специализированных классов по типу: UltraMegaSuperman: GameUnit, а просто являются контейнерами для набора Component. То что определенная сущность является тем самым UltraMegaSuperman определяет ее набор компонент и их параметров.
Например в нашем случае главный герой имел следующие компоненты:- TextureComponent — определяет, что рисовать на экране
- TransformComponent — положение объекта в игровом пространстве
- VelocityComponent — скорость движения объекта в игровом пространстве
- HeroControllerComponent — содержит значения влияющие на движение героем
- ImmortalityTimeComponent — содержит оставшееся время бессмертия
- DynamicComponent — указывает что объект не статичен и подвержен гравитации
- BodyComponent — определяет физическое 2d тело героя, необходимое для вычисления столкновений
- System — содержат код обработки данных с компонентов каждой сущности. Они обязательно не должны хранить объекты Entity и/или Component, так как это будет противоречить паттерну. В идеале они вообще должны быть чистыми.
Системы выполняют всю грязную работу: отрисовать все объекты игры, переместить объект по его скорости, проверить столкновения, изменить скорость от входящего управления и так далее. К примеру влияние гравитации выглядит так:
override fun processEntity(entity: Entity, deltaTime: Float) { entity.getComponent(VelocityComponent::class.java) .velocity .add(0f, -GRAVITY * deltaTime) }
Специализация каждой системы определяет требования к сущностям которые она должна обработать. То есть в примере выше сущность должна иметь компонент скорости VelocityComponent и DynamicComponent, чтобы эту сущность можно было обработать, в ином случае системе эта сущность не интересна, и так с остальными. Для прорисовки текстуры к примеру нужно знать какую текстуру TextureComponent и где рисовать TransformComponent. Для определения требований в каждой системе в конструкторе прописывается Family в котором указываются классы компонентов.
Family.all(TransformComponent::class.java, TextureComponent::class.java).get()
Также порядок обработки сущностей внутри системы можно регулировать компаратором. Помимо этого порядок выполнения систем в движке тоже регулируется значением приоритета.
Движок объединяет три компонента. Он содержит все системы и все сущности в игре. При старте игры все необходимые в игре системы
engine.apply {
addSystem(ControlSystem())
addSystem(GravitySystem())
addSystem(renderingSystem)
addSystem(MovementSystem())
addSystem(EnemyGeneratorSystem())
}
а также стартовые сущности добавляются в движок,
val hero: Entity = engine.createEntity()
engine.addEntity(hero)
PooledEngine::createEntity — достает объект сущности из пула, так как сущности можно создавать и во время игры, чтобы не засорять память. При необходимости они достаются из пула объектов, а при удалении помещаются обратно. Аналогично сделано и для компонентов. При получении компонент из пула необходимо инициализировать все поля, так как в них может содержатся информация предыдущего использования этого компонента.
Связь между основными частями паттерна представлена ниже:
Движок содержит в себе коллекцию систем и коллекцию сущностей. Каждая система получает от движка ссылку на коллекцию сущностей, которая являет собой выборку из общей коллекции по требованиям системы, она будет обновляться по ходу игры при изменениях сущностей и компонент. Каждая сущность содержит коллекцию своих компонент определяющих ее в игре.
Игровой цикл построен следующим образом:
- Пользуясь реализацией паттерна “Игровой цикл” от LibGDX, мы в методе update получаем в каждый такт времени его прирост — deltaTime.
- Далее передаем время в движок. А он в свою очередь перебирает системы по циклу раздает им deltaTime.
for (i in 0 until systems.size()) { val system = systems[i] if (system.checkProcessing()) { system.update(deltaTime) } }
- Системы получив deltaTime перебирают свои сущности и применяют к ним изменения с учетом deltaTime.
for (i in 0 until entities.size()) { processEntity(entities[i], deltaTime) }
Так происходит каждый такт игры.
Преимущества ECS
- Данные на первом месте. Так как системы обрабатывают лишь те сущности которые им подходят, то при отсутствии таких сущностей система просто ничего не будет делать, это дает возможность тестировать и отлаживать новые фичи создавая только необходимые для этого сущности.
Например, вы создали игру «танчики». Через какое-то время вы решили добавить новый тип местности — «лава». Если танк попытается проехать по ней, то это закончится фиаско. Но на помощь приходит футуристическая технология, установив которую можно пересечь лаву.
Для отладки такого случая вам не обязательно создавать полные модели танков и строить полные карты с добавленными локациями лавы — достаточно продумать минимально необходимые компоненты на танке, и добавить сущность в движок игры чтобы протестировать. Все это звучит очевидно, но на деле встречаешь класс TheTank который в конструкторе просит список параметров: калибр, скорость, спрайт, скорость выстрела, фамилии экипажа и т.д. хотя для тестирования пересечения лавы это ненужно.
- Также, по примеру из предыдущего пункта отмечаем большую гибкость, так как при таком подходе добавлять и удалять фичи значительно легче.
Реальный пример. Игровой сценарий нашей второй промки заключался в том, что пользователь исполнив песню за ~2 минуты врезался в линию финиша, а игра начинала отсчет заново перезагружая уровень, но с сохранением баллов, тем самым давая передышку игроку.
За пару дней до предполагаемого релиза приходит таск «убрать финиш и сделать отчет на полчаса непрерывной игры с зацикливанием препятствий и песни». Глобальное изменение, но сделать это было очень легко — достаточно было удалить сущность финиша и добавить систему отсчета конца игры.
- Все наработки легко тестировать. Зная что данные на первом месте можно моделировать любые тестовые случаи, прогонять их и смотреть результат.
- В перспективе, для валидации состояния игры, к игровому процессу можно подключать сервер. Он будет прогонять через тот же код те же входные данные клиента и сравнивать свой результат с результатом на клиенте. Если данные не сходятся, то значит клиент читер или у нас есть ошибки в работе игры.
ECS в большом мире gamedev
Большие компании, как Unity, Epic или Crytek используют этот шаблон в своих фреймворках чтобы предоставить разработчикам инструмент с кучей возможностей. Советую посмотреть доклад о том как реализовывалась геймплейная логика в Overwatch
Для большего понимания сделал небольшой пример на github.
Спасибо за внимание!
Также по теме:
Автор: R1KUD0