Привет! Представляю вашему вниманию перевод вики проекта Svelto.ECS, написанного Себастьяно Мандала (Sebastiano Mandalà).
Svelto.ECS — результат многолетних исследований и применения принципов SOLID в разработке игр на Unity. Это одна из многих реализаций паттерна ECS, доступная для C# с различными уникальными функциями, введенными для устранения недостатков самого паттерна.
Первый взгляд
Самый простой способ увидеть основные возможности Svelto.ECS — загрузить Vanilla Example. Если вы хотите убедиться в простоте его использования, я покажу вам пример:
//Устанавливаем фреймворк
void ApplicationCompositionRoot()
{
var simpleSubmissionEntityViewScheduler = new SimpleSubmissionEntityViewScheduler();
_enginesRoot = new EnginesRoot(simpleSubmissionEntityViewScheduler);
var entityFactory = _enginesRoot.GenerateEntityFactory();
var entityFunctions = _enginesRoot.GenerateEntityFunctions();
_enginesRoot.AddEngine(new BehaviourForSimpleEntityEngine(entityFunctions));
entityFactory.BuildEntity<SimpleEntityDescriptor>(new EGID(1), new[]
{
new SimpleImplementor()
});`
}
//Определяем сущность
class SimpleEntityDescriptor : GenericEntityDescriptor<BehaviourEntityViewForSimpleEntity>
{
}
public class BehaviourEntityViewForSimpleEntity : EntityView
{
public ISimpleComponent simpleComponent;
}
public interface ISimpleComponent
{
public int counter {get; set;}
}
class SimpleImplementor : ISimpleComponent
{
public int counter { get; set; }
}
//Определяем движок (систему) как поведение сущности
public class BehaviourForSimpleEntityAsStructEngine : IQueryingEntityViewEngine
{
public IEntityViewsDB entityViewsDB { private get; set; }
public void Ready()
{
Update().Run();
}
//Так выглядит цикл движка.
//Движок предназначен для обработки N сущностей, где N также может быть 0 и 1.
IEnumerator Update()
{
Console.Log("Task Waiting");
while (true)
{
var entityViews =
entityViewsDB
.QueryGroupedEntityViews<BehaviourEntityViewForSimpleEntity>(0);
if (entityViews.Length> 0)
{
for (var i = 0; i < entityViews.Length; i++)
AddOne(entityViews[i].counter);
Console.Log("Task Done");
yield break;
}
yield return null;
}
}
static void AddOne(int counter)
{
counter += 1;
}
}
К сожалению, невозможно быстро понять теорию, лежащую в основе этого кода, который может выглядеть простым, но одновременно запутанным. Для понимания потребуется потратить время на чтение «стены текста» и опробовать приведенные примеры.
Введение
В последнее время я много обсуждал Svelto.ECS с несколькими, более или менее опытными программистами. Я собрал много отзывов и сделал много заметок, которые буду использовать в качестве отправной точки для своих следующих статей, где я буду больше говорить о теории и хороших практиках. Небольшой спойлер: я понял, что, когда начинаешь использовать Svelto.ECS, самое большое препятствие — смена парадигмы программирования. Удивительно, сколько я должен написать, чтобы объяснить новые концепции, представленные Svelto.ECS, по сравнению с тем небольшим количеством кода, написанного для разработки фреймворка. Фактически, в то время как сам фреймворк очень простой и облегченный, переход от ООП с активным применением наследования или обычных компонентов Unity, к «новому» модульному и слабосвязанному дизайну, который Svelto.ECS предлагает использовать, мешает людям адаптироваться к фреймворку.
Svelto.ECS активно используется в Freejam (прим. переводчика — Автор является техническим директором в этой компании). Поскольку я всегда могу объяснить коллегам основные концепции фреймворка, у них уходит меньше времени на понимание работы с ним. Хотя Svelto.ECS является настолько жестким, насколько это возможно, вредные привычки трудно побороть, поэтому пользователи склонны злоупотреблять некоторой гибкостью, позволяющей адаптировать фреймворк к «старым» парадигмам, с которыми им удобно. Это может привести к катастрофе из-за недопонимания или извращения концепций, лежащих в основе логики фреймворка. Вот почему я намерен написать как можно больше статей, тем более, что я уверен, что парадигма ECS — лучшее на данный момент решение, для написания эффективного и поддерживаемого кода для крупных проектов, которые меняются и переделываются по многу раз за несколько лет. Robocraft и Cardlife являются тому доказательством.
Я не собираюсь много говорить о теориях, лежащих в основе этой статьи. Я лишь напомню, почему я отказался от использования IoC контейнера и начал использовать исключительно ECS фреймворк: IoC контейнер — это очень опасный инструмент, если он используется без понимания самой сути инверсии управления. Как вы могли узнать из моих предыдущих статей, я различаю между собой инверсию управления созданием (Inversion of Creation Control) и инверсию управления потоком (Inversion of Flow Control). Инверсия управления потоком — это как принцип Голливуда: «Не звоните нам, мы позвоним вам». Это означает, что внедренные зависимости никогда не должны использоваться напрямую через публичные методы, так как при этом вы просто используете IoC контейнер в качестве замены любой другой формы глобальной инъекции, например синглтонов. Однако, если IoC контейнер используется по принципу Инверсии управления (IoC), то в основном всё сводится к многократному использованию паттерна “Шаблонный метод” для внедрения менеджеров, используемых только для регистрации объектов, которыми они управляют. В реальном контексте инверсии управления потоком менеджеры всегда отвечают за управление сущностями. Это похоже на паттерн ECS? Безусловно. Исходя из этого рассуждения я взял паттерн ECS и разработал на его основе жесткий фреймворк, и его использование равносильно применению новой парадигмы программирования.
Composition Root и EnginesRoot
Класс Main — это Корень композиции (Composition Root) приложения. Корень композиции — это то место, где создаются и внедряются зависимости (я много об этом рассказывал в своих статьях). Корень композиции принадлежит контексту, но контекст может иметь более одного корня композиции. Например, Фабрика (Factory) является корнем композиции. В приложении может быть более одного контекста, но это продвинутый сценарий, и в этом примере мы его рассматривать не будем.
Прежде чем погрузиться в код, давайте познакомимся с первыми правилами языка Svelto.ECS. ECS — это аббревиатура Сущность Компонент Система (Entity Component System). Инфраструктура ECS была хорошо проанализирована в статьях многими авторами, но в то время как основные концепции являются общими, реализации сильно различаются. Прежде всего, нет стандартного способа решения некоторых проблем, возникающих при использовании ECS-ориентированного кода. Именно в отношении этого вопроса я прилагаю большую часть своих усилий, но об этом я расскажу позже или в следующих статьях. В основе теории лежат понятия Сущности, Компонентов (сущностей) и Систем. Хотя я понимаю, почему исторически использовалось слово Система, я с самого начала не считал его достаточно интуитивно понятным для этой цели, поэтому я использовал Движок как синоним Системы, и вы, в зависимости от ваших предпочтений, можете применять один из этих терминов.
Класс EnginesRoot является ядром Svelto.ECS. С его помощью можно регистрировать движки и конструировать все сущности игры. Создавать движки динамически не имеет особого смысла, поэтому они все должны быть добавлены в экземпляр EnginesRoot из того же корня композиции, где он был создан. По аналогичным причинам экземпляр EnginesRoot никогда не должен внедряться, а движки не должны удаляться после того, как были добавлены.
Чтобы создавать и внедрять зависимости, нам нужен, по крайней мере, один корень композиции. Да, в одном приложении вполне может существовать более одного EnginesRoot, но мы не будем касаться этого в текущей статье, которую я стараюсь максимально упростить. Вот как выглядит корень композиции с созданием движков и внедрением зависимостей:
void SetupEnginesAndEntities()
{
//Engines Root это ядро Svelto.ECS. Вы НИКОГДА не должны внедрять EngineRoot
//Как есть, поэтому Composition Root должен содержать ссылку на него, или он
//будет собран сборщиком мусора.
//UnitySumbmissionEntityViewScheduler - это планировщик, который используется
//EnginesRoot, чтобы знать когда внедрять EntityViews.
//Вы не должны использовать свой, если не знаете,
//что вы делаете или если вы не работаете с Unity.
_enginesRoot = new EnginesRoot(new UnitySumbmissionEntityViewScheduler());
//Engines root никогда не должен содержать что либо, кроме самого контекста,
//чтобы избежать утечек памяти.
//Именно поэтому создаются EntityFactory и EntityFunctions.
//EntityFactory может быть внедрен в фабрики
//(Или движки используемые как фабрики),
//чтобы динамически создавать сущности.
_entityFactory = _enginesRoot.GenerateEntityFactory();
//Класс EntityFunctions содержит набор утилитарных функций
//для выполнения на сущностях,
//включая удаление сущности. Я пока не предумал более подходящего названия
var entityFunctions = _enginesRoot.GenerateEntityFunctions();
//GameObjectFactory позволяет создавать Unity GameObject
//без использования статического
//метода GameObject.Instantiate. Хотя это кажется лишним усложнением
// важно, чтобы двигатели были тестируемыми, а не
// связанными с ссылками на жесткие зависимости
//(почитайте мои статьи, чтобы понять
// как работает инъекция зависимостей и почему разрешать зависимости
// с статическими классами и синглтонами - ужасная ошибка)
GameObjectFactory factory = new GameObjectFactory();
//Паттерн наблюдатель один из 3 официальных способов коммуникации в Svelto.ECS.
//Он должен использоваться для коммуникации между движками в
//очень специфических случаях.
//Это не предпочтительное решение и в основном используется для
//коммуникации между устаревшим и сторонним кодом.
var enemyKilledObservable = new EnemyKilledObservable();
var scoreOnEnemyKilledObserver = new ScoreOnEnemyKilledObserver(enemyKilledObservable);
//ISequencer один из 3 официальных путей доступных в Svelto.ECS
//для коммуникации. Они используются для решения двух специфических задач:
//1) Указать строгий порядок выполнения между движками
//(Логика движков выполняется
//горизонтально, а не вертикально, Я рассказывал об этом
//в своих статьях).
//2) Отфильтровать токен данных, переданный как параметр через
// движки. ISequencer не основной способ для коммуникации
//между движками
Sequencer playerDamageSequence = new Sequencer();
Sequencer enemyDamageSequence = new Sequencer();
//Обертка для статических классов Unity.
//В дальнейшем может использоваться для тестирования.
IRayCaster rayCaster = new RayCaster();
ITime time = new Others.Time();
//Движки игрока. ВСЕ зависимости должны быть установлены в этом месте
//внедрением через конструктор.
var playerHealthEngine = new HealthEngine(entityFunctions, playerDamageSequence);
var playerShootingEngine = new PlayerGunShootingEngine(enemyKilledObservable, enemyDamageSequence, rayCaster, time);
var playerMovementEngine = new PlayerMovementEngine(rayCaster, time);
var playerAnimationEngine = new PlayerAnimationEngine();
//Движки врагов
var enemyAnimationEngine = new EnemyAnimationEngine();
var enemyHealthEngine = new HealthEngine(entityFunctions, enemyDamageSequence);
var enemyAttackEngine = new EnemyAttackEngine(playerDamageSequence, time);
var enemyMovementEngine = new EnemyMovementEngine();
var enemySpawnerEngine = new EnemySpawnerEngine(factory, _entityFactory);
//Интерфейс и звуковые движки
var hudEngine = new HUDEngine(time);
var damageSoundEngine = new DamageSoundEngine();
//Реализация Sequencer очень проста, но позволяет выполнять
//сложную конкатенацию, включая петли и условное ветвление.
playerDamageSequence.SetSequence(
new Steps //Последовательность шагов, является словарем!
{
{ //Первый шаг
//Этот шаг выполняется только через функцию Next этого движка
enemyAttackEngine,
new To //этот шаг может привести только к одной ветви
{
//Это единственный движок который будет вызван
//при срабатывании функции Next
playerHealthEngine,
}
},
{ //Второй шаг
playerHealthEngine,
//Этот шаг выполняется только через функцию Next этого движка
new To //Этот шаг может разветвиться на два пути
{
//Эти движки будут вызваны когда функция Next будет вызвана с условием
//DamageCondition.damage
{ DamageCondition.damage, new IStep[] {
hudEngine,
damageSoundEngine }
},
//Эти движки будут вызваны когда функция Next будет вызвана с условием
//DamageCondition.dead
{ DamageCondition.dead, new IStep[] {
hudEngine,
damageSoundEngine,
playerMovementEngine,
playerAnimationEngine,
enemyAnimationEngine }
},
}
}
});
enemyDamageSequence.SetSequence(
new Steps
{
{
playerShootingEngine,
new To
{
enemyHealthEngine,
}
},
{
enemyHealthEngine,
new To
{
{ DamageCondition.damage, new IStep[] {
enemyAnimationEngine,
damageSoundEngine }
},
{ DamageCondition.dead, new IStep[] {
enemyMovementEngine,
enemyAnimationEngine,
playerShootingEngine,
enemySpawnerEngine,
damageSoundEngine }
},
}
}
});
//Главный шаг, чтобы заставить движки работать
//Движки игрока
_enginesRoot.AddEngine(playerMovementEngine);
_enginesRoot.AddEngine(playerAnimationEngine);
_enginesRoot.AddEngine(playerShootingEngine);
_enginesRoot.AddEngine(playerHealthEngine);
_enginesRoot.AddEngine(new PlayerInputEngine());
_enginesRoot.AddEngine(new PlayerGunShootingFXsEngine());
//Движки врагов
_enginesRoot.AddEngine(enemySpawnerEngine);
_enginesRoot.AddEngine(enemyAttackEngine);
_enginesRoot.AddEngine(enemyMovementEngine);
_enginesRoot.AddEngine(enemyAnimationEngine);
_enginesRoot.AddEngine(enemyHealthEngine);
//Остальные движки
_enginesRoot.AddEngine(new CameraFollowTargetEngine(time));
_enginesRoot.AddEngine(damageSoundEngine);
_enginesRoot.AddEngine(hudEngine);
_enginesRoot.AddEngine(new ScoreEngine(scoreOnEnemyKilledObserver));
Этот код — из примера Survival, который теперь прокомментирован и соответствует почти всем правилам хороших практик, которые я предлагаю применять, в том числе использование платформонезависимой и тестируемой логики движков. Комментарии помогут вам понять большинство из них, но проект такого размера может быть сложен для понимания, если вы новичок в Svelto.
Сущности
Первый шаг после создания пустого корня композиции и экземпляра класса EnginesRoot должен идентифицировать объекты, с которыми вы хотите работать в первую очередь. Логично начать с Сущности Player. Сущность Svelto.ECS не следует путать с Игровым Объектом (GameObject) Unity. Если вы читали другие статьи, связанные с ECS, то могли видеть, что во многих из них сущности часто описываются как индексы. Вероятно, это худший способ ввести концепцию ECS. Хоть это справедливо и для Svelto.ECS, в нем это скрыто. Я хочу, чтобы пользователь Svelto.ECS представлял, описывал и идентифицировал каждую сущность с точки зрения языка предметной области игры (Game Design Domain language). Сущность в коде должна быть объектом, описанным в дизайн-документе игры. Любая другая форма определения сущности приведет к надуманному способу адаптации ваших старых представлений к принципам Svelto.ECS. Следуйте этому основополагающему правилу, и вы не ошибетесь. Класс сущности сам по себе не существует в коде, но вы все равно должны определять его не абстрактно.
Движки
Следующий шаг — подумать о том, какое поведение задать Сущности. Каждое поведение всегда моделируется внутри Движка, нельзя добавлять логику в любые другие классы внутри приложения Svelto.ECS. Мы можем начать с передвижения персонажа игрока и определить класс PlayerMovementEngine. Название движка должно быть очень узконаправленным, поскольку чем оно конкретнее, тем вероятней, что Движок будет следовать Правилу Единственной Ответственности (Single Responsibility Rule). Правильное именование классов в Svelto.ECS имеет фундаментальное значение. И цель не только в том, чтобы четко показать ваши намерения, но и в том, чтобы помочь вам самим «увидеть» их.
По этой же причине важно, чтобы ваш движок находился в очень специализированном пространстве имен. Если вы определяете пространства имен в соответствии с структурой папок, приспосабливайтесь к понятиям Svelto.ECS. Использование конкретных пространств имен помогает обнаружить ошибки проектирования, когда сущности используются внутри несовместимых пространств имен. Например, не предполагается, что какой-либо объект-враг будет использоваться внутри пространства имен игрока, если целью не стоит нарушить правила, связанные с модульностью и слабой связанностью объектов. Идея состоит в том, что объекты определенного пространства имен могут использоваться только внутри него самого или родительского пространства имен. Применяя Svelto.ECS гораздо сложнее превратить ваш код в спагетти, где зависимости внедряются направо и налево, а это правило поможет вам ещё выше поднять планку качества кода, когда зависимости правильно абстрагируются между классами.
В Svelto.ECS абстракция выдвигается на несколько рубежей, но ECS по существу способствует абстрагированию данных из логики, которая должна обрабатывать данные. Сущности определяются их данными, а не их поведением. Движки таком случае — это место, где можно поместить совместное поведение одинаковых сущностей, чтобы движки всегда могли работать с набором сущностей.
Svelto.ECS и парадигма ECS позволяют кодеру достичь одного из святых граалей чистого программирования, которым является идеальная инкапсуляция логики. Двигатели не должны иметь публичных функций. Единственные публичные функции, которые должны существовать, — это те, которые необходимы для реализации интерфейсов фреймворка. Это приводит к забыванию инъекции зависимостей и помогает избежать плохого кода, возникающего при использовании инъекции зависимостей без инверсии контроля. Движки НИКОГДА не должны внедряться ни в один другой движок или какой-либо другой тип класса. Если вы думаете, что хотите внедрить движок, вы просто сделаете принципиальную ошибку дизайна кода.
По сравнению с Unity MonoBehaviours, движки уже показывают первое огромное преимущество, которое представляет собой возможность доступа ко всем состояниям сущностей данного типа из той же области кода. Это означает, что код может легко использовать состояние всех объектов непосредственно из того же места, где будет выполняться логика общего объекта. Кроме того, отдельные движки могут обрабатывать одни и те же объекты, чтобы движок мог изменять состояние объекта, в то время как другой движок мог его прочитать, эффективно используя два движка для коммуникации через одни и те же данные сущности. Пример можно увидеть глядя на движки PlayerGunShootingEngine и PlayerGunShootingFxsEngine. В этом случае два движка находятся в одном пространстве имен, поэтому они могут совместно использовать одни и те же данные сущности. PlayerGunShootingEngine определяет, был ли поврежден игрок (враг), и записывает значение lastTargetPosition компонента IGunAttributesComponent (который является компонентом PlayerGunEntity). PlayerGunShootFxsEngine обрабатывает графические эффекты оружия и считывает позицию цели выбранной игроком. Это пример взаимодействия между движками посредством опроса данных (data polling). Позже в этой статье я покажу, как позволить механизму общаться между ними посредством проталкивания данных (Data pushing) или привязки данных (Data binding). Логично, что движки никогда не должны хранить состояние.
Движки не должны знать, как взаимодействовать с другими движками. Внешняя связь происходит через абстракцию, а Svelto.ECS решает связь между движками тремя разными официальными способами, но об этом я расскажу позже. Лучшие движки — это те, которые не требуют каких-либо внешних коммуникаций. Эти движки отражают хорошо инкапсулированное поведение и обычно работают через логический цикл. Циклы всегда моделируются с помощью задач Svelto.Task внутри приложений Svelto.ECS. Поскольку движение игрока должно обновляться каждый физический тик, было бы естественно создать задачу, выполняемую в каждое физическое обновление. Svelto.Tasks позволяет запускать каждый тип IEnumerator на нескольких типах планировщиков. В этом случае мы решили создать задачу на PhysicScheduler, которая позволяет обновить позицию игрока:
public PlayerMovementEngine(IRayCaster raycaster, ITime time)
{
_rayCaster = raycaster;
_time = time;
_taskRoutine = TaskRunner.Instance.AllocateNewTaskRoutine()
.SetEnumerator(PhysicsTick()).SetScheduler(StandardSchedulers.physicScheduler);
}
protected override void Add(PlayerEntityView entityView)
{
_taskRoutine.Start();
}
protected override void Remove(PlayerEntityView entityView)
{
_taskRoutine.Stop();
}
IEnumerator PhysicsTick()
{
//Я предпологаю, что сущность игрока уже создана
//и добавлена EnginesRoot когда этот код запускается.
//Я предполагаю, что в массиве сущностей есть только одна сущность игрока.
var _playerEntityViews = entityViewsDB.QueryEntityViews<PlayerEntityView>();
var playerEntityView = _playerEntityViews[0];
while (true)
{
Movement(playerEntityView);
Turning(playerEntityView);
//Не забудьте использовать yield, или войдете в бесконечный цикл!
yield return null;
}
}
Задачи Svelto.Tasks могут выполняться напрямую или через объекты ITaskRoutine. Я не буду здесь много говорить о Svelto.Tasks, поскольку я написал для него другие статьи. Причина, по которой я решил использовать подпрограмму задачи вместо того, чтобы запускать реализацию IEnumerator напрямую, довольно дискреционная. Я хотел показать, что можно запустить цикл, когда объект игрока добавлен в движок и остановить его при его удалении. Однако для этого нужно знать, когда объект добавляется и удаляется.
Svelto.ECS вводит обратные вызовы для добавления и удаления, чтобы знать, когда определенные сущности добавляются или удаляются. Это нечто уникальное в Svelto.ECS, но этот подход следует использовать с умом. Я часто видел, что этими обратными вызовами злоупотребляют, так как во многих случаях их достаточно, чтобы запрашивать сущности. Даже наличие ссылки на сущность в качестве поля движка должно рассматриваться больше как исключение, чем правило.
Только когда эти обратные вызовы должны быть использованы, движок должен наследоваться либо от SingleEntityViewEngine, либо от MultiEntitiesViewEngine <EntityView1, ..., EntityViewN>. Опять-таки использование этих данных должно быть редким, и они никоим образом не намерены сообщать, какие объекты будет обрабатывать движок.
Движки чаще всего реализуют интерфейс IQueryingEntityViewEngine. Это позволяет получить доступ к базе данных сущностей и извлекать данные из нее. Помните, что вы всегда можете запросить какой-либо объект изнутри движка, но в тот момент, когда вы запрашиваете сущность, которая несовместима с пространством имен, где находится движок, вы должны понимать, что уже делаете что-то неправильно. Движки никогда не должны предполагать, что сущности доступны, и должны работать над набором объектов. Не следует предполагать, что в игре всегда будет только один игрок, как я делаю в примере кода. В EnemyMovementEngine находится очень общий подход к тому, как запрашивать объекты:
public void Ready()
{
Tick().Run();
}
IEnumerator Tick()
{
while (true)
{
var enemyTargetEntityViews = entityViewsDB.QueryEntityViews<EnemyTargetEntityView>();
if (enemyTargetEntityViews.Count > 0)
{
var targetEntityView = enemyTargetEntityViews[0];
var enemies = entityViewsDB.QueryEntityViews<EnemyEntityView>();
for (var i = 0; i < enemies.Count; i++)
{
var component = enemies[i].movementComponent;
component.navMeshDestination = targetEntityView.targetPositionComponent.position;
}
}
yield return null;
}
}
В этом случае основной цикл движка запускается непосредственно на предопределенном планировщике. Tick ().Run() показывает самый короткий способ запуска IEnumerator с Svelto.Tasks. IEnumerator будет продолжать уступать следующему кадру, пока не будет найдена хотя бы одна цель Enemy. Поскольку мы знаем, что всегда будет только одна цель (другое нехорошее предположение), я выбираю первую доступную. В то время как цель Enemy Target может быть только одной (хотя могло быть и больше!), Врагов много, и движок все-таки заботится о логике движения для всех. В этом случае я схитрил, поскольку на самом деле я использую Unity Nav Mesh System, поэтому все, что мне нужно сделать, это просто установить точку назначения в NavMesh. Честно говоря, я никогда не использовал код Unity NavMesh, поэтому я даже не уверен, как он работает, этот код просто унаследован от оригинальной демонстрации Survival.
Обратите внимание, что компонент никогда не предоставляет напрямую зависимость Navmesh Unity. Компонент Сущности, как я расскажу позже, должен всегда выставлять типы значений. В этом случае это правило также позволяет сохранить код под контролем, так как тип значения поля navMeshDestination может быть позже реализован без использования Unity Nav Mesh.
Для завершения параграфа, относящегося к движкам, обратите внимание на то, что нет такого понятия, как слишком маленький движок. Следовательно, не бойтесь писать движок содержащий нескольких строк кода, ведь вы не можете писать логику где-то еще, и вам нужно, чтобы ваши движки следовали правилу единой ответственности.
Представления сущности
До этого мы ввели концепцию Движка и абстрактное определение Сущности, давайте теперь определим, что такое Представление сущности. Я должен признать, что из 5 концепций, на которых построен Svelto.ECS, Представления сущностей, вероятно, являются самыми запутанными. Ранее названные Узлом (Node) (название, взятое из ECS фреймворка Ash), я понял, что название “Узел” ничего не значило. EntityView также может вводить в заблуждение, поскольку программисты обычно ассоциируют представления с концепцией, исходящей из шаблона Модель Представление Контроллер (Model View Controller), однако в Svelto.ECS используется View, потому что EntityView — это то, как Движок видит Сущность. Мне нравится описывать его так, поскольку это кажется наиболее естественным, но я мог бы также назвать его EntityMap, поскольку EntityView отображает компоненты сущности, к которым должен обращаться движок. Эта схема концепций Svelto.ECS должна немного помочь:
Я предлагаю начинать работу с Движка, и сейчас мы находимся на правой стороне этой схемы. Каждый движок имеет собственный набор EntityViews. Движок может повторно использовать совместимые с пространством имен EntityViews, но чаще всего Движок определяет его EntityViews. Движок не заботится о том, действительно ли определена сущность Player, он констатирует тот факт, что ему нужен PlayerEntityView для работы. Написание кода зависит от потребностей Движка, вы не должны создавать сущность и её поле, прежде чем поняли как их использовать. В более сложном сценарии имя EntityView могло бы быть еще более конкретным. Например, если нам пришлось бы писать сложные движки для обработки логики игрока и рендеринга графики игрока (или анимации и т. д.), Мы могли бы иметь PlayerPhysicEngine с PlayerPhysicEntityView, а также PlayerGraphicEngine с PlayerGraphicEntityView или PlayerAnimationEngine с PlayerAnimationEntityView. Можно использовать более конкретные имена, такие как PlayerPhysicMovementEngine или PlayerPhysicJumpEngine (и т. д.).
Компоненты
Мы поняли, что движки моделируют поведение для набора данных сущностей, и мы понимаем, что движки не используют сущности напрямую, а используют компоненты сущности через представления сущностей. Мы поняли, что EntityView — это класс, который может содержать ТОЛЬКО открытые (public) компоненты сущностей. Я также намекнул, что компоненты сущностей всегда являются интерфейсами, поэтому давайте дадим лучшее определение:
Сущности представляют собой набор данных, а компоненты сущностей — это способ доступа к этим данным. Если вы еще этого не заметили, определение компонентов сущности как интерфейсов является еще одной довольно уникальной особенностью Svelto.ECS. Обычно компоненты в других фреймворках являются объектами. Использование интерфейсов вместо этого позволяет значительно сократить код. Если вы следуете принципу «Разделение интерфейса» (Interface Segregation Principle), написав небольшие интерфейсы компонентов, даже с одним свойством каждый, вы заметите, что начали повторно использовать интерфейсы компонентов внутри разных сущностей. В нашем примере ITransformComponent повторно используется во многих представлениях сущности. Использование компонентов в качестве интерфейсов также позволяет им реализовывать одни и те же объекты, что во многих случаях позволяет упростить связь между сущностями, которые видят одну и ту же сущность с помощью разных представлений сущностей (или одной и той же, если это возможно).
Поэтому в Svelto.ECS компонент сущности всегда является интерфейсом, и этот интерфейс используется только через поле EntityView внутри Движка. Интерфейс компонента сущности затем реализуется так называемым «Имплементором». Теперь мы начинаем определять саму Сущность, и находимся в левой части вышеприведенной схемы.
Компоненты должны всегда хранить значимые типы, и поля всегда являются свойствами. Исключения могут быть сделаны только для того, чтобы писать сеттеры и геттеры в качестве методов для использования ключевого слова ref, когда необходима оптимизация. Это не означает, что код ориентирован на данные (data oriented), но он позволит создавать код для тестов, поскольку логика движка не должна обрабатывать ссылки на внешние зависимости. Кроме того, это мешает кодерам обманывать фреймворк и использовать публичные функции (которые могут включать логику!) случайных объектов. Единственная причина, по которой можно было почувствовать необходимость использования ссылок внутри интерфейсов компонентов сущностей, — это иметь дело с зависимостями третьих сторон, такими как объекты Unity. Тем не менее, пример Survival, показывает, как справиться с этим, оставляя тестовый код движков без необходимости заботиться о зависимостях Unity.
Дескрипторы Сущности
Именно здесь Дескрипторы сущностей приходят на помощь, чтобы собрать все вместе. Мы знаем, что движки могут получать доступ к данным Сущности через Компоненты, которые хранятся в Представлениях сущности. Мы знаем, что движки являются классами, EntityView — это классы, которые содержат только Компоненты сущности и что Компоненты являются интерфейсами. Хотя я дал абстрактное определение Сущности, мы не видели ни одного класса, который фактически представляет собой Сущность. Это соответствует концепции объектов, являющихся идентификаторами внутри современной системы ECS. Однако без правильного определения Сущности это заставит кодеров идентифицировать Сущности с Представлениями сущностей, что было бы катастрофически неправильным. Представления сущностей — это способ, которым несколько Движков могут видеть одну и ту же Сущность, но они не являются Сущностями. Сама Сущность всегда должна рассматриваться как набор данных, определенных через Компоненты сущности, но даже это — слабое определение. Экземпляр EntityDescriptor дает возможность кодеру правильно определять свои Сущности независимо от движков, которые будут обрабатывать их. Поэтому в случае с Сущностью Player нам понадобится PlayerEntityDescriptor. Этот класс будет использоваться для создания Сущности, и хотя то, что он действительно делает, является чем-то совершенно другим, тот факт, что пользователь может писать BuildEntity<PlayerEntityDescriptor>(), помогает очень просто визуализировать Сущности для построения и сообщить о намерениях другим кодерам.
Однако то, что действительно делает EntityDescriptor, — это создает список EntityViews!!! На ранних этапах разработки фреймворка я разрешал кодерам создавать этот список EntityViews вручную, что приводило к очень уродливому коду, поскольку он больше не мог визуализировать то, что на самом деле происходило.
Вот как выглядит PlayerEntityDescriptor:
using Svelto.ECS.Example.Survive.Camera;
using Svelto.ECS.Example.Survive.HUD;
using Svelto.ECS.Example.Survive.Enemies;
using Svelto.ECS.Example.Survive.Sound;
namespace Svelto.ECS.Example.Survive.Player
{
public class PlayerEntityDescriptor : GenericEntityDescriptor<HUDDamageEntityView,
PlayerEntityView, EnemyTargetEntityView, DamageSoundEntityView, HealthEntityView,
CameraTargetEntityView>
{
}
}
Дескрипторы сущностей (и Имплементоры) являются единственными классами, которые могут использовать идентификаторы из нескольких пространств имен. В этом случае PlayerEntityDescriptor определяет список EntityViews для создания экземпляра и внедрения в движок при создании PlayerEntity.
EntityDescriptorHolder
EntityDescriptorHolder является расширением для Unity и должен использоваться только в определенных случаях. Наиболее распространенным является создание своего рода полиморфизма, хранящего информацию о Сущности для построения Unity GameObject. Таким образом, один и тот же код может использоваться для создания нескольких типов Сущностей. Например, в Robocraft мы используем единую фабрику кубов, которая строит все кубы из которых состоят машины. Тип куба для сборки хранится в префабе самого куба. Это хорошо, пока имплементоры одинаковы между кубами или найдены в GameObject как MonoBehaviour’s. Создавать Сущности напрямую предпочтительнее, поэтому используйте EntityDescriptorHolders только тогда, когда вы правильно поняли принципы Svelto.ECS, иначе существует риск злоупотребления ими. Эта функция из примера показывает, как использовать класс:
void BuildEntitiesFromScene(UnityContext contextHolder)
{
//EntityDescriptorHolder - это специальный класс Svelto.ECS созданный,
//чтобы динамично извлекать данные сущности из игровых объектов.
//Игровые объекты могут содеражть всю информацию необходимую для создания сущности.
//Это позволяет создать своего рода полиморфный код,
//который может быть переиспользован
//для создания разных типов сущностей
IEntityDescriptorHolder[] entities = contextHolder.GetComponentsInChildren<IEntityDescriptorHolder>();
//Это достаточно общий паттерн в Svelto.ECS, добавленный, чтобы автоматически
//создавать сущности из объектов представленных в сцене.
//Я предпочитаю избегать этот способ и создавать сущности напрямую.
//Вам следует избегать использования EntityDescriptorHolder,
//когда это не необходимо
for (int i = 0; i < entities.Length; i++)
{
var entityDescriptorHolder = entities[i];
var entityDescriptor = entityDescriptorHolder.RetrieveDescriptor();
_entityFactory.BuildEntity
(((MonoBehaviour) entityDescriptorHolder).gameObject.GetInstanceID(),
entityDescriptor,
(entityDescriptorHolder as MonoBehaviour).GetComponentsInChildren<IImplementor>());
}
}
Обратите внимание, что в этом примере я использую менее предпочтительную, не обобщенную функцию BuildEntity. Я поясню это. В этом случае имплементоры являются классами MonoBehaviour присоединенными к GameObject. Это не очень хорошая практика. Я должен был удалить этот код из примера, но оставил, чтобы показать вам этот особенный случай. Имплементоры, как мы увидим дальше, должны быть классами MonoBehaviours только тогда, когда это необходимо!
Имплементоры
Прежде чем создавать свою сущность, давайте определим последнюю концепцию в Svelto.ECS, которой является Имплементор. Как мы знаем, Компоненты сущности это всегда интерфейсы, а интерфейсы C# должны быть реализованы. Объект, реализующий эти интерфейсы, называется «Имплементором». У Имплементоров есть несколько важных характеристик:
- Возможность отвязать количество объектов для сборки от количества компонентов сущности, необходимых для определения данных сущности.
- Возможность обмениваться данными между разными Компонентами, поскольку Компоненты предоставляют данные через свойства, разные свойства Компонента могут возвращать одно и то же поле реализации.
- Возможность создания заглушки интерфейса компонента сущности. Это важно для того, чтобы оставить тестируемым код движка.
- Действуют как мост между Движками Svelto.ECS и сторонними (third party) платформами. Эта характеристика имеет фундаментальное значение. Если вам нужен Unity, чтобы общаться с движками, вам не нужно использовать неудобные обходные пути, просто создайте имплементор как наследник Monobehaviour. Таким образом, вы можете использовать внутри имплементора обратные вызовы Unity, такие как OnTriggerEnter / OnTriggerExit, и изменять данные в соответствии с обратным вызовом Unity. Не следует использовать логику внутри этого обратного вызова, за исключением установки данных Компонентов сущности. Вот пример:
public class EnemyTriggerImplementor : MonoBehaviour, IImplementor,
IEnemyTriggerComponent, IEnemyTargetComponent
{
public event Action<int, int, bool> entityInRange;
bool IEnemyTriggerComponent.targetInRange { set { _targetInRange = value; } }
bool IEnemyTargetComponent.targetInRange { get { return _targetInRange; } }
void OnTriggerEnter(Collider other)
{
if (entityInRange != null)
entityInRange(other.gameObject.GetInstanceID(), gameObject.GetInstanceID(), true);
}
void OnTriggerExit(Collider other)
{
if (entityInRange != null)
entityInRange(other.gameObject.GetInstanceID(), gameObject.GetInstanceID(), false);
}
bool _targetInRange;
}
Помните, что степень разбиения ваших, Компонентов сущностей и Имплементоров полностью зависит от вас. Чем мельче они разбиты, тем проще их повторно использовать.
Создание Сущностей
Предположим, что мы создали наши Движки, добавили их в EnginesRoot, создали их Представления сущности, которым нужны Компоненты в качестве интерфейсов, которые будут реализованы внутри Имплементоров. Настало время создать нашу первую Сущность. Сущность всегда создается через экземпляр Фабрики сущностей (Entity Factory), созданный EnginesRoot через функцию GenerateEntityFactory. В отличие от экземпляра EnginesRoot экземпляр IEntityFactory можно внедрять и передавать. Объекты могут быть построены внутри корня композиции или динамически внутри фабрик, поэтому для последнего случая необходимо передать IEntityFactory через параметр.
IEntityFactory идет с несколькими похожими функциями. В рамках этой статьи я пропущу объяснение функций PreallocateEntitySlots и BuildMetaEntity, чтобы сосредоточиться на наиболее часто используемых функциях BuildEntity и BuildEntityInGroup.
Лучше всегда использовать BuildEntityInGroup, но для примера Survival на не понадобится, поэтому давайте посмотрим, как обычный BuildEntity используется в примере:
IEnumerator IntervaledTick()
{
//Одно важное замечание: Никогда не создавайте имплементоры в виде
//MonoBehaviour только для хранения данных.
//Данные всегда должны извлекаться через сервисный уровень
//независимо от источника данных.
//Выгоды многочисленны, в том числе тот факт,
//что для изменения источника данных потребуется изменить только код сервиса.
//В этом простом примере я не использую Сервисный слой, но в целом идея ясна.
//Также обратите внимание, что я загружаю данные только один раз
//для каждого запуска приложения,
//вне основного цикла. Вы всегда можете использовать этот трюк,
//если данные, которые вам нужны, не будут изменяться.
var enemiestoSpawn = ReadEnemySpawningDataServiceRequest();
while (true)
{
//Svelto.Tasks позволяет использовать стандартные yield операторы Unity,
//но они могут снижать производительность.
//Поэтому самым быстрым решением будет использование собственных перечислителей.
//Честно говоря, разница минимальна,
//но лучше этим не злоупотреблять.
yield return _waitForSecondsEnumerator;
if (enemiestoSpawn != null)
{
for (int i = enemiestoSpawn.Length - 1; i >= 0 && _numberOfEnemyToSpawn > 0; --i)
{
var spawnData = enemiestoSpawn[i];
if (spawnData.timeLeft <= 0.0f)
{
//Ищем случайный индекс между нулем и количеством точек для спавна
int spawnPointIndex = Random.Range(0, spawnData.spawnPoints.Length);
//Создаем экземпляр префаба врага в случайно выбранной точке.
var go = _gameobjectFactory.Build(spawnData.enemyPrefab);
//Здесь я поленился и извлек данные напрямую из MonoBehaviour.
//Я не создаю имплементор для этой цели.
var data = go.GetComponent<EnemyAttackDataHolder>();
//Здесь мы используем смесь из MonoBehaviour имплеметоров и
//нормальных имплементоров:
List<IImplementor> implementors = new List<IImplementor>();
go.GetComponentsInChildren(implementors);
implementors.Add(new EnemyAttackImplementor(data.timeBetweenAttacks, data.attackDamage));
//В этом примере каждый вид врага генерирует одинаковый список EntityViews,
//поэтому я всегда использую одинаковый EntityDescriptor.
//Однако, если разные враги должны создавать разные EntityView
//для разных движков, это был бы хороший пример, где EntityDescriptorHolder
//мог бы использоваться для использования своего рода полиморфизма,
//который описывается в моих статьях.
_entityFactory.BuildEntity<EnemyEntityDescriptor>(
go.GetInstanceID(), implementors.ToArray());
var transform = go.transform;
var spawnInfo = spawnData.spawnPoints[spawnPointIndex];
transform.position = spawnInfo.position;
transform.rotation = spawnInfo.rotation;
spawnData.timeLeft = spawnData.spawnTime;
numberOfEnemyToSpawn--;
}
spawnData.timeLeft -= 1.0f;
}
}
}
}
Не забудьте прочитать все комментарии в этом примере, они помогут еще лучше понять концепции Svelto.ECS. Из-за простоты примера я не использую BuildEntityInGroup, который применяется в более сложных проектах. В Robocraft каждый движок, который обрабатывает логику функциональных кубов, обрабатывает логику ВСЕХ функциональных кубов этого конкретного типа в игре. Однако, часто необходимо знать, к какому транспортному средству принадлежат кубы, поэтому использование группы для каждой машины поможет разбить кубы одного и того же типа по машинам, где идентификатор машины — это идентификатор группы. Это позволяет нам реализовывать классные штуки, такие как запуск одной задачи Svelto.Tasks на машину внутри одного и того же движка, который может работать параллельно с использованием многопоточности.
Этот фрагмент кода показывает одну важную проблему, которую я, возможно, освещу подробнее в следующих статьях… из комментария (если вы его не читали):
Никогда не создавайте Имплементоры в виде MonoBehaviour только для хранения данных. Данные всегда должны извлекаться через Сервисный слой независимо от источника данных. Выгоды многочисленны, в том числе тот факт, что для изменения источника данных потребуется изменить только код сервиса. В этом простом примере я не использую Сервисный слой, но в целом идея ясна. Также обратите внимание, что я загружаю данные только один раз для каждого запуска приложения, вне основного цикла. Вы всегда можете использовать этот трюк, если данные, которые вам нужны, никогда не изменяются.
Первоначально я считывал данные непосредственно из MonoBehaviour, как это сделал бы хороший ленивый кодер. Это заставило меня создать имплементор в виде MonoBehaviour только для чтения сериализованных данных. Это приемлемо, если мы не хотим абстрагировать источник данных, однако намного лучше сериализовывать информацию в json-файл и считывать её по запросу к сервису, чем читать эти данные из Компонента сущности.
Коммуникация в Svelto.ECS
Одна из проблем, решение которой никогда не было стандартизировано ни одной реализацией ECS, — это связь между системами. Это еще одно место, где я много думал, и Svelto.ECS решает его двумя новыми способами. Третий способ — использование стандартного паттерна Наблюдатель / Наблюдаемый, приемлемого в очень конкретных и специфических случаях.
DispatchOnSet / DispatchOnChange
Ранее мы увидели, как позволить Движкам обмениваться данными через Компоненты сущностей с помощью опроса данных (Data polling). DispatchOnSet и DispatchOnChange являются единственными ссылками (не значимыми типами), которые могут быть возвращены свойствами Компонентов сущностей, но тип универсального параметра T должен быть значимым типом. Названия функций звучат как диспетчер событий, но вместо этого их следует рассматривать как методы проталкивания (Push) данных, как противоположные опросам данных, что немного похоже на привязку данных. Вот и все, иногда опрос данных неудобен, мы не хотим опрашивать переменную каждый кадр, когда знаем, что данные редко меняются. DispatchOnSet и DispatchOnChange не могут запускаться без изменения данных, это позволяет рассматривать их как механизм привязки данных вместо обычного события. Также нет функции запуска для вызова, вместо этого значение данных, удерживаемых этими классами, должно быть установлено или изменено. В коде Survival нет больших примеров, но вы можете увидеть, как работает булево поле targetHit из IGunHitTargetComponent. Разница между DispatchOnSet и DispatchOnChange заключается в том, что последний запускает событие только тогда, когда данные фактически изменяются, а первое всегда.
Секвенсер
Идеальные Движки полностью инкапсулированы, и вы можете написать логику этого движка в виде последовательности инструкций с использованием Svelto.Tasks и перичеслителями (IEnumerators). Однако это не всегда возможно, так как в некоторых случаях Движки должны отправлять события другим Движкам. Обычно это выполняется через данные Сущности, особенно с использованием DispatchOnSet и DispatchOnChange, однако, как и в случае Сущностей, “поврежденных” в примере, на нем действует серия независимых и несвязанных Движков. В других случаях вы хотите, чтобы последовательность была строгой в порядке вызова движков, как в примере, где я хочу, чтобы смерть всегда происходила для последнего. В этом случае последовательность не только очень проста в использовании, но и очень удобна! Рефакторинг последовательностей очень прост. Поэтому используйте IEnumerator Svelto Tasks для «вертикальных» движков и последовательности для «горизонтальной» логики между движками.
Наблюдатель / Наблюдаемый
Я оставил возможность использовать этот паттерн специально для случаев, когда устаревший код или код не использующий Svelto.ECS должен взаимодействовать с движками Svelto.ECS. Для остальных случаев его следует использовать с особой осторожностью, так как существует вероятность злоупотребления паттерном, так как он знаком большинству кодеров, новичков в Svelto.ECS, и Секвенсоры, как правило, являются, лучшим выбором.
Автор: Григорий Зайченков