Skip to content

Latest commit

 

History

40 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 

Repository files navigation

image

DragonECS-Vault

Данный репозиторий - это сборник заметок и рекомендаций по коду для фреймворка 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.

image

Note

Диаграмма взята со страницы codewriter-packages/Morpeh.Events, так как она очень качественно демонстрирует эту идею.

Выбор Self или Target

Для 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-методы опускают все проверки, поэтому неправильное использование может привести к нестабильному состоянию компонентов фреймворка или всего проекта.

К Unchecked-методам стоит относиться как к unsafe и применять их только в узких местах, где критична производительность. Если вы не уверены, что делаете, лучше их не использовать.

About

A set of notes and code recommendations for the DragonECS framework.

Resources

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors