Данный репозиторий - это сборник заметок и рекомендаций по коду для фреймворка DragonECS. Все, что здесь описано, построено на моем личном опыте и на подходах, которыми я сам пользуюсь в проектах.
Материал, расположенный в этом репозитории, не является сводом правил, не обязателен для ознакомления и имеет только рекомендательный характер.
Местами материал может быть спорным, поэтому спорные темы приглашаю обсудить в Discord
Обычная версия с интерфейсами инъекции:
class ApplyVelocitySystem : IEcsRun, IEcsInject<EcsDefaultWorld>, IEcsInject<TimeService>
{
EcsDefaultWorld _world;
TimeService _time;
class Aspect : EcsAspect
{
public EcsPool<Pose> Poses = Inc;
public EcsPool<Velocity> Velocities = Inc;
public EcsTagPool<FreezedTag> FreezedTags = Exc;
}
public void Run()
{
foreach (var e in _world.Where(out Aspect a))
{
a.Poses.Get(e).position += a.Velocities.Get(e).value * _time.DeltaTime;
}
}
public void Inject(EcsDefaultWorld obj) => _world = obj;
public void Inject(TimeService obj) => _time = obj;
}Тот же пример, но с Auto-Injections:
class ApplyVelocitySystem : IEcsRun
{
[DI] EcsDefaultWorld _world;
[DI] TimeService _time;
class Aspect : EcsAspect
{
public EcsPool<Pose> Poses = Inc;
public EcsPool<Velocity> Velocities = Inc;
public EcsTagPool<FreezedTag> FreezedTags = Exc;
}
public void Run()
{
foreach (var e in _world.Where(out Aspect a))
{
a.Poses.Get(e).position += a.Velocities.Get(e).value * _time.DeltaTime;
}
}
}В системе сначала удобно держать поля и зависимости, затем аспект, затем основной метод системы. Методы Inject можно убирать в самый низ класса, потому что они относятся к подключению зависимостей, а не к основной логике системы.
Аспект стоит располагать ближе к методу, который с ним работает, обычно прямо перед Run. Так описание выборки остается на виду во время написания логики, и его проще быстро редактировать рядом с местом использования.
Модификаторы доступа private/public для членов систем обычно не нужны. Взаимодействие с системами происходит либо косвенно через данные в компонентах, либо напрямую через интерфейсы. Поэтому для сокращения бойлерплейта и улучшения читаемости модификаторы доступа можно опускать там, где это возможно.
Иногда можно встретить рекомендацию, что системы должны быть полностью stateless. На практике точнее говорить, что система не должна быть источником истины для состояния игры.
В системе можно хранить кэши, ссылки на зависимости, временные данные для оптимизации или служебное состояние самой системы. Но состояние игры должно жить в сущностях и компонентах, либо во внешних сервисах и данных, получаемых через dependency inversion. Если потеря или пересоздание системы меняет фактическое состояние игры, скорее всего это состояние лежит не там.
Хотя аспекты могут использоваться несколькими системами одновременно, удобнее объявлять для каждой системы свой аспект прямо внутри нее.
Многие системы работают только с одним аспектом, поэтому аспект можно называть просто Aspect. Если аспектов несколько, основной также можно назвать Aspect, а второстепенные - с префиксом, например EventAspect.
Поля для кэша пулов стоит называть по названию компонента во множественном числе и с заглавной буквы, например EcsPool<Health> Healths. Для этого пулы формально реализуют IEnumerable<T>, чтобы автодополнение IDE предлагало такое имя.
Возвращаемый запросом Where экземпляр аспекта можно называть просто a, а сущность внутри foreach - просто e. Например, foreach (var e in _world.Where(out Aspect a)).
Если система работает с несколькими аспектами, к a и e добавляется префикс. Например, foreach (var eventE in _world.Where(out EventAspect eventA)).
Группы систем, компонентов и сообщений, объединенные одной логикой, лучше организовывать как отдельные фичи. Модуль в этом подходе является инструментом DragonECS для подключения фичи в пайплайн. При необходимости фичи можно выносить в отдельные сборки.
Папка одной фичи имеет следующую иерархию:
.../
+-- SomeFeature/
+-- Components/
| +-- SomeComponent.cs
| +-- IsTagged.cs
| +-- SomeRequest.cs
| +-- SomeAnswer.cs
| +-- SomeEvent.cs
| ...
+-- _SomeFeatureModule.cs
+-- SomeSystem1.cs
+-- SomeSystem2.cs
...
Components/- папка для обычных компонентов, тегов и сообщений фичи.SomeComponent.cs- обычный компонент.IsTagged.cs- компонент-тег, который используется какbool-флаг. Такие компоненты рекомендуется называть по аналогии сbool-полями, то есть с префиксомIs.SomeRequest.cs,SomeAnswer.cs,SomeEvent.cs- сообщения фичи._SomeFeatureModule.cs- класс, реализующий интерфейсIEcsModuleи добавляющий системы фичи в пайплайн.SomeSystem1.cs,SomeSystem2.cs- системы фичи, лежащие в корне папки фичи.
Фичи удобно группировать через модуль-агрегатор. Это может быть модуль, который в методе Import просто добавляет модули других фич. Например, фичи, которые могут работать независимо от Unity, можно объединить в модуль ProjectCoreModule, а Unity-зависимые - в ProjectUnityModule. После такого разделения в EcsRoot достаточно добавить эти два модуля.
Для систем и компонентов, относящихся к одной фиче, стоит добавлять мета-атрибут MetaGroup, а в качестве корневой группы использовать название модуля фичи.
// Суффикс Module из _SomeFeatureModule будет автоматически удален, останется _SomeFeature
[MetaGroup(nameof(_SomeFeatureModule))]
public struct SomeComponent : IEcsComponent
{
//...
}Следующей подгруппой можно указать, чем является тип, компонентом или системой. Для этого в EcsConsts есть готовые константы.
[MetaGroup(nameof(_SomeFeatureModule), EcsConsts.COMPONENTS_GROUP)]
public struct SomeComponent : IEcsComponent { /* ... */ }
[MetaGroup(nameof(_SomeFeatureModule), EcsConsts.SYSTEMS_GROUP)]
public class SomeSystem : IEcsRun { /* ... */ }В ECS обмен между системами чаще всего происходит через компоненты. Система оставляет данные в мире, а другие системы находят их через свои аспекты и реагируют на них в своем порядке обработки. Сообщения фичи лежат в Components/, потому что технически это такие же компоненты. Их удобно разделять по роли в потоке данных на Request, Answer и Event.
-
Requestописывает намерение получить действие или данные. Его могут создавать разные системы, но обрабатывать должна одна система-владелец запроса. Она же обычно отвечает за очисткуRequest. Если в проекте есть общая очистка сообщений в концеUpdate, можно переложить очистку туда. -
Answerописывает результат обработкиRequest, например успешный ответ, найденные данные или причину отказа. По механике он близок кEvent, но по смыслу направлен обратно к системе, которая создалаRequest, либо к другой связанной системе внутри той же фичи. -
Eventописывает уже произошедший факт. Его создает одна система, а реагировать на него могут многие. Ответственность за очисткуEventлежит на системе, которая его создала, либо на общей очистке сообщений в концеUpdate.
Note
Диаграмма взята со страницы codewriter-packages/Morpeh.Events, так как она очень качественно демонстрирует эту идею.
Для Request и Event важно выбрать форму хранения. Сообщение можно повесить на целевую сущность или вынести в отдельную сущность-сообщение.
Если для одной цели достаточно одного сообщения за цикл обработки, компонент можно повесить прямо на эту цель. В таком случае в название добавляется маркер Self. Он показывает, что сообщение относится к сущности, на которой лежит сам компонент.
public struct DamageSelfEvent : IEcsComponent
{
public float Points;
}Если для одной цели может существовать несколько независимых сообщений, лучше создать отдельную сущность-сообщение и явно указать цель через поле вида entlong Target. В этом случае Self в название не добавляется.
public struct DamageEvent : IEcsComponent
{
public entlong Target;
public float Points;
}Note
Идея нейминга Request, Answer и Event была взята из этой статьи, проверена на практике и подтвердила себя как эффективный нейминг.
Важно различать отдельный факт, агрегированные данные и состояние игры. Например, если цель получила три удара за один tick, это можно представить по-разному.
Три отдельные DamageRequest-сущности сохранят источник, порядок и индивидуальные эффекты каждого удара. Один DamageSelfRequest на цели с операцией += сохранит только общий результат, то есть станет намеренной агрегацией. Health после применения урона - это уже состояние цели, а не сообщение.
Агрегация на цели подходит, когда происхождение отдельных фактов неважно. Но если атакующему нужно начислить опыт, применить life steal, обновить конкретную способность или связать результат с конкретным источником, отдельные сущности-сообщения масштабируются лучше.
Не каждое событие обязано становиться сущностью. Иногда обычный массив, список, стек, очередь или ring buffer яснее, чем набор ECS-сущностей. Такой вариант лучше подходит, если поток очень частый, строго упорядоченный, имеет одного владельца или потребителя, обрабатывается транзакционно и не нуждается в ECS-композиции или независимых фильтрах.
Сущность-сообщение выигрывает, когда событие является полноценными данными мира. Например, содержит составные данные или должно быть независимо обработано несколькими системами. При этом не стоит превращать каждую низкоуровневую деталь в отдельный Event, так легко получить запутанные причинные цепочки и частично выполненные сценарии. Иногда один доменный факт вроде ItemPickedUpEvent с несколькими обработчиками лучше набора мелких команд вроде "добавить предмет в инвентарь", "проиграть звук", "показать всплывающий текст", "обновить квест".
Фреймворк поддерживает создание нескольких миров и их совместную обработку в системах. Это опциональная возможность. Если вы только начинаете работать с ECS или вам неудобно пользоваться этой особенностью, все можно помещать в один мир.
Разделение может быть полезно с точки зрения использования памяти. Группы сущностей, которые по своей специфике не могут иметь общих аспектов с другими, можно выделять в отдельные миры. Типичный пример такого разделения - дефолтный мир, где обрабатываются игровые сущности, и мир событий, где обрабатываются сущности-события. У игровых и событийных сущностей редко будут пересечения аспектов, поэтому их можно выделить в отдельные миры.
Для хранения в компонентах ссылок на сущности между мирами категорически рекомендуется использовать entlong, так как у entlong есть привязка к миру. Иначе высока вероятность "магических" ошибок из-за путаницы идентификаторов.
В фреймворке некоторые методы имеют две версии, основную и с суффиксом Unchecked. Unchecked-методы опускают все проверки, поэтому неправильное использование может привести к нестабильному состоянию компонентов фреймворка или всего проекта.
К Unchecked-методам стоит относиться как к unsafe и применять их только в узких местах, где критична производительность. Если вы не уверены, что делаете, лучше их не использовать.

