Система компонентов cущностей (Entity Component System)
Использование паттерна “Компонент” – это единственная альтернатива не потеряться среди леса из деревьев наследования. Я поясню.
Когда люди работают над сложными механиками, они пытаются выделить общие части системы, чтобы их переиспользовать. Но так случается, что общие части должны присутствовать в совершенно несвязанных модулях.
Частая ошибка – пытаться объединить то, что должно лежать раздельно. Из-за того, что общий функционал нужен в двух несвязанных ветках наследования, а большинство языков не поддерижвают множественное наследование, то люди извращаются как могут. Впиливают куда-нибудь наверх еще уровень наследования, чтобы где-то через несколько уровней вниз, 2 класса использовали 1-2 метода.
Часто еще бывает, что этот Common класс не используется целиком, и среди всей иерархии тащатся бесполезные части.
Component pattern
Если использовать шаблон Component, то все упрощается. Мы просто меняем наследование на композицию, добавляем там где нам нужно компонент, и вызываем методы.
Теперь у нас ничего лишнего в родительских классах, все выглядит почище. Не смотря на то, что решение простое, и, казалось бы, интуитивное, люди, почему-то, все равно хотят использовать наследование, как одержимые.
Entity Component Pattern
Я не смог найти правильное название для этого шаблона. Это что-то среднее между использование компонентов, что я рассмотрел ранее, и Entity Component System, что мы рассмотрим позже.
Предыдущий подход имеет один большой недостаток: использование всех компонентов зашито в коде. То есть, мы не можем добавить к классу игрока новый компонент, не поменяв код.
Это может быть довольно сильным ограничением, ведь геймплей должен быть настраиваемым. Если каждый раз гейм-дизайнер будет дергать программиста, чтобы изменить скорость перемещения персонажа – это катастрофа.
Для этого в играх есть некая система для конфигурации. Если можно конфигурировать то что уже написано в коде, так почему бы не дать еще больше свободы для креатива и не “хардкодить” поведение заранее.
Шаблон Entity Component – это то, как работает вся система в Unity. Идея, на самом деле неплохая, но реализация так себе. О ее недостатках все известно, но мы часто забываем о преимуществах.
Данный шаблон предполагает, что все в программе – это некая “Сущность (Entity)”. В терминах Unity сущность – это GameObject. И каждая сущность может иметь набор компонентов. Они заранее не определены и их список может меняться посредством методов.
Если откинуть мысли о производительности, то давайте посмотрим какое преимущество нам дает такая система.
Классический пример.
Есть, например, шутер. В нем есть игроки, которые палят друг в друга, пока не прикончат. Внезапно гейм-дизайнер решил, что в игре должны быть и разрушаемые предметы, причем разрушаться они должны после определенного урона.
Компоненты позволяют отделить код от конфигурации/представления. Поэтому, если программисты делали свою работу хорошо, и сделали отдельный компонент, который имеет “здоровье” и может принимать урон, то разрушение чего-угодно решается простым добавлением компонента к объекту.
В юнити, гейм-дизайнер может открыть “префаб” камня, например, и добавить к нему соответствующий компонент. Если бы у нас не было такой возможности, то программисту понадобилось бы искать условный класс “Камень” и добавлять компонент в код.
Немного программер-арта:
HealthComponent приаттачен как к плеерам, так и к камню, что позволяет сразу получить весь необходимый функционал, включая отображение полосок здоровья.
Еще одна приятная возможность такой системы, что компоненты можно аттачить к объектам в рантайме, по какому-либо событию, что тоже позволяет делать интересные механики.
Это классный паттерн, но у него есть свои недостатки:
мы не знаем какие компоненты есть у объекта заранее, можем определить это только в рантайме, что рождает все эти ошибки, когда мы пытаемся использовать компонент, которого нет
зависимости между компонентами сложнее реализовать и отслеживать
такая система бьет по производительности, так как многие вещи делаются в рантайме
Но преимущества сильно перевешивают недостатки, имхо.
Entity Component System
На самом деле, шаблон Entity Component System (ECS) очень похож на предыдущий вариант. С одним ключевым отличием: бизнес логика по обработке компонентов лежит в системах, а не в самих компонентах.
В отличие от предыдущего подхода, где, например, метод Update присутствует в каждом отдельном компоненте, в ECS есть система обработки компонентов, которая имеет список всех компонентов одного типа, и, итерируясь по ним, исполняет бизнес логику.
В чем здесь выгода?
Порядок обработки компонентов системами четко регламентирован
В каком порядке системы были зарегистрированы в ECS, в таком они и обработают компонент. Это дает высокую предсказуемость кода. Когда отлаживаешь баги, то почти сразу можно локализовать место, где данные поломались.
Легко включать и выключать логику
Попробуйте в Unity отключить все MonoBehavior определенного типа. Это возможно, но будет проблематично:
Найти все компоненты типа T и выключить их: FindObjectsOfType, который по сути итерируется по всем объектам в сцене.
Сделать статическую переменную в классе компонента и проверять ее внутри Update и других методов, т.е. по сути исполнять N раз одну и ту же работу в каждом компоненте.
Прикреплять компоненты одного типа к одному game object и включать/выключать его
Если у вас есть система физики, которая работает с рядом физических компонентов, обновляя их, то выключить физику можно простейшим выключением апдейта самой системы.
Аналогично, если в Update есть какое-то вычисление, одинаковое для всех компонентов, то система его может закэшировать.
Итерация и обработка однотипных компонентов происходит гораздо быстрее.
Система может манипулировать несколькими типами компонентов и эффективно их обрабатывать, решая проблему зависимостей и взаимодействия. Даже если у вас несколько типов компонентов, код логично сгруппирован в системе.
Легко писать тесты
Так как системы сосредоточены на одной задаче, слабо связаны и оперируют только данными в компонентах, то писать тесты – сущая легкотня.
Фикстуры – это просто набор компонентов-данных.
Мокать ничего практически не нужно.
Проверять вход и выход системы в тестах – элементарно!
Модульность кода
ECS позволяет организовывать модульный код. Имеется ввиду, что какая-то фича может состоять из ряда компонентов и группы систем по их обработке. Они должны быть связаны вместе, потому что не могут работать друг без друга (вспоминаем high cohesion). Вместе они образуют фича-модуль.
Если правильно организовать код и держать связанные вещи рядом, организуя их в модули, а между модулями выстроить границы (вспоминаем loose coupling), то получится классная архитектура.
К примеру, начиная новый прототип, можно легко перетаскивать и подключать целые модули. Здесь мы хотим модуль стрельбы от первого лица, а вот здесь мы хотим NAVMeshAI для мобов, а вот здесь мы подключим модуль с индикаторами здоровья врагов.
А если мы резко передумали и решили что-то выпилить – тоже не проблема, не нужно перелопачивать весть код удаляя там-сям.
Вот, например, пачка систем оружия из реального проекта, которы могут быть объединены в модуль:
Практический кейс: используем плюшки низкой связности ECS для привязки аналитики
Интеграция аналитики в проект – всегда геморная задача. Нет, дело не в сложности, а в том, что красиво аналитику в проект вставить сложно. Она часто “размазана” по коду то там, то здесь. Она редко ложится в архитектуру.
Хорошая новость! С ECS вставить аналитику можно красиво! Не зря же я заговорил про модули.
Приведу пример из проекта на Entitas.
Во-первых, я решил отвязать системы геймплея от аналитики. Для этого создал отдельный датакласс для колбеков из геймплея.
Для примера, рассмотрим систему для сборки статистики урона от мобов.
publicclassDamageStatisticsSystem:IExecuteSystem{privatereadonlyAction<Dictionary<DamageDescriptor,float>>_onReportDamageStatistics;privatereadonlySingletonEntity_uniqueEntity;privatereadonlyIGroup<EventsEntity>_damageEvents;privatereadonlyIGroup<GameEntity>_activeCharacterGroup;publicDamageStatisticsSystem(SuperContextsuperContext,[CanBeNull]Action<Dictionary<DamageDescriptor,float>>onReportDamageStatistics){_onReportDamageStatistics=onReportDamageStatistics;_uniqueEntity=superContext.uniqueEntity;_uniqueEntity.isDamageStatistics=true;_damageEvents=superContext.eventsContext.GetGroup(EventsMatcher.DamageEvent);_activeCharacterGroup=superContext.gameContext.GetGroup(GameMatcher.AllOf(GameMatcher.ActiveCharacter,GameMatcher.Character));_uniqueEntity.runData.runData.damageStatistics.Clear();// make sure each room has clean stats}publicvoidExecute(){if(!_uniqueEntity.isDamageStatistics)return;varactiveCharacterEntity=_activeCharacterGroup.GetSingleEntity();varstatistics=_uniqueEntity.runData.runData.damageStatistics;foreach(vardamageEventin_damageEvents){varisActiveCharacterDamage=damageEvent.damageEvent.targetId==activeCharacterEntity.character.id;if(!isActiveCharacterDamage)continue;vardamageDescriptor=GetDamageDescriptor(damageEvent.damageEvent);vardamage=statistics.GetValueOrDefault(damageDescriptor,0L);statistics[damageDescriptor]=damage+damageEvent.damageEvent.damage;}if(!_uniqueEntity.hasScenarioCompleted&&!activeCharacterEntity.isDead)return;_onReportDamageStatistics?.Invoke(_uniqueEntity.runData.runData.damageStatistics);_uniqueEntity.isDamageStatistics=false;}privatestaticDamageDescriptorGetDamageDescriptor(DamageEventComponentdamageEventComponent)=>newDamageDescriptor(){botAttack=damageEventComponent.botAttackRef,botPrototype=damageEventComponent.botPrototypeRef,damageZone=damageEventComponent.damageZoneRef};}
Ключевое в этой системе, что она использует компоненты из других систем, такие как DamageEvent или ActiveCharacter, но никак не влияет на сами эти системы.
Просто делает свое дело, накапливая статистику в RunData – это контекст забега в игре.
А когда уровень завершен (опять же, логика завершения уровня лежит где-то в других системах), то дергает соответствующий колбек, отдавая данные вовне.
Итак, что же такого хорошего в таком подходе?
Аналитика разбита между отдельными системами. Каждая система делает только одну вещь и делает ее хорошо – Single responsibility principle
Системы аналитики не имеют своего состояния. Они используют общедоступные данные в компонентах ECS. Другие системы ничего не знают об аналитике – Loose coupling
Системы аналитики никак напрямую не привязаны к отправке этой самой аналитике. Т.е. условный транспорт может быть любой и может меняться независимо.
Системы аналитики организованы в отдельный “модуль” и могут легко подключаться и отключаться
Подводим итоги
Люди все еще думают классическими терминами ООП, и стремятся “унаследовать” все подряд. Для механики игр больше подходят другие решения вроде Entity Component System (ECS). И да, если вы не пробовали писать код с таким подходом, то скорее всего придется немножко сломать голову и поменять мышление.
Принято считать, что испытательный срок — для сотрудников.
Вы приходите на новую работу и чувствуете себя не в своей тарелке, ведь вы пока не уверены, что сп...
Bugs in your company get lost in the chat, users leave, management blames engineers, engineers feel guilty and anxious, get burned out? We’ve been there. Her...
Most of the articles and books tell you how to write a “good code”. But in real life, you often find yourself deep in the shit after joining some company or ...