CyberGrad 2033 Архитектура: Ecs
Предыдущий пост: CyberGrad 2033
На старте энтити пробегается по своим чилдам и если они являются компонентами, добавляет в словарь.
Компоненты не содержат логики (хотя никто не запретит), толко данные и могут содержать ссылки на другие ноды
У систем есть несколько коллбэков, которые имплементируются интерфесами
IInitSystem — вызовется один раз при старте игры
IDestroySystem — вызовется один раз при завершении
IEntitiesAdded — вызывается когда появляется новый энтити, подходящий под фильтр системы (только один раз)
IEntitiesUpdate — вызывается каждый кадр и содержит всех энтитей подходящих под фильтр
IEntitiesPhysicsUpdate — то же самое но каждый апдейт физики
IComponentsRemoved — вызывается когда энтити больше не подходит под фильтр
Сервисы умеют в следующие интерфейсы:
IInit — на старте игры один раз
IUpdate — каждый кадр
Без DI сервис можно использовать так (про DI будет в следующем посте):
И чтобы эта пуля работало нужно несколько систем, например
MoveSystem — двигает любые энтити у которых есть компонент MoveComponent
LifeTimeSystem — удаляет энтити с компонентом LifeTimeComponent через заданное в нем время
Взглянем на LifeTimeSystem
1. Говорим что нужны только энтити с компонентом LifeTimeComponent
2. Получаем компонент у каждой энтити по очереди и уменьшаем таймер, если время пришло — убиваем
И да, компоненты можно добавлять прямо через стандартный диалог добавления нод
PS. На самом деле я тут описал низкоуровневые сущности и, в принципе, можно пользоваться прям так.
Но есть более удобные способы, обертки, которые немного упрощают код и сразу интегрированны с DI контейнером
Об этом как-нибудь в следйющий раз
Сразу оговорюсь, данная реализация лишь внешне выглядит как Ecs, под капотом это скорее всего что-то совершенно другое, т.к я не в курсе как выглядят настоящие Ecs
Так же тут нет никакой претензии на производительность, главными приоритетами были удобство, простота и нативная интеграция с движком.
Основные сущности, на которых все держиться: Энтити, компоненты, системы и сервисы.
Entity
Это класс, наследник Node, в нем я реализовал методы добавления/удаления компонентов.На старте энтити пробегается по своим чилдам и если они являются компонентами, добавляет в словарь.
Components
Это тоже наследник Node, потому что я хочу иметь возможность добавлять компоненты через редакторКомпоненты не содержат логики (хотя никто не запретит), толко данные и могут содержать ссылки на другие ноды
Systems
Тут происходит вся логика с энтитями, но только с теми, которые попадают под фильтр этой системы.У систем есть несколько коллбэков, которые имплементируются интерфесами
IInitSystem — вызовется один раз при старте игры
IDestroySystem — вызовется один раз при завершении
IEntitiesAdded — вызывается когда появляется новый энтити, подходящий под фильтр системы (только один раз)
IEntitiesUpdate — вызывается каждый кадр и содержит всех энтитей подходящих под фильтр
IEntitiesPhysicsUpdate — то же самое но каждый апдейт физики
IComponentsRemoved — вызывается когда энтити больше не подходит под фильтр
Services
Немного походяд на системы, но если система работает с энтитями, то сервисы это просто утилитарные классы, которые могут делать что угодно. К примеру AudioService — который воспроизводит звуки в игре.Сервисы умеют в следующие интерфейсы:
IInit — на старте игры один раз
IUpdate — каждый кадр
Без DI сервис можно использовать так (про DI будет в следующем посте):
var audioService = Service.GetService<AudioService>();
audioService.PlaySound(audioStream);
Как это все работает на практике
Вот как у меня выглядит энтити пулиИ чтобы эта пуля работало нужно несколько систем, например
MoveSystem — двигает любые энтити у которых есть компонент MoveComponent
LifeTimeSystem — удаляет энтити с компонентом LifeTimeComponent через заданное в нем время
Взглянем на LifeTimeSystem
public class LifeTimeSystem : SystemBase, IEntitiesUpdate
{
[Inject] private EcsWorld _world = null!;
public LifeTimeSystem() : base(new EcsFilter().With<LifeTimeComponent>())
{
}
public void EntitiesUpdate(Dictionary<ulong, EcsEntity> entities, float delta)
{
foreach (var (_, entity) in entities)
{
var lifeTime = entity.GetComponent<LifeTimeComponent>()!;
lifeTime.ValueSeconds -= delta;
if (lifeTime.ValueSeconds <= 0)
_world.RemoveEntity(entity);
}
}
}
1. Говорим что нужны только энтити с компонентом LifeTimeComponent
new EcsFilter().With<LifeTimeComponent>()
Можно также использовать Without, к примеру:public LifeTimeSystem() : base(new EcsFilter()
.With<LifeTimeComponent>()
.Without<ImmortalComponent>())
{
}
2. Получаем компонент у каждой энтити по очереди и уменьшаем таймер, если время пришло — убиваем
И да, компоненты можно добавлять прямо через стандартный диалог добавления нод
Entrypoint
Ну и нужно сделать точку входа, где будет вся инициализация
public partial class MainGame : Node
{
private EcsWorld _world = null!;
private Logger _logger = Logger.GetLogger("MainGame");
public override void _Ready()
{
// Get EcsWorld
_world = EcsWorld.Instance!;
// Services
Service.Register(new AudioService(this));
// Systems
_world.RegisterSystem(new MoveSystem());
_world.RegisterSystem(new LifeTimeSystem());
_world.Initialize();
_logger.Info("Game initialized");
}
public override void _Process(double delta)
{
var dt = (float)delta;
_world.Tick(dt);
Service.Tick(dt);
}
public override void _PhysicsProcess(double delta) => _world.PhysicsTick((float)delta);
public override void _ExitTree() => _world.UnregisterAllSystems();
}
PS. На самом деле я тут описал низкоуровневые сущности и, в принципе, можно пользоваться прям так.
Но есть более удобные способы, обертки, которые немного упрощают код и сразу интегрированны с DI контейнером
Об этом как-нибудь в следйющий раз
Просто я видел разные подходы и они все были со своими недостатками.
Ещё вопрос про компоненты. У тебя можно прицепить несколько компонент одного типа к одному объекту? Выглядит так что можно (раз в коде есть компонент типа DamageReceiveComponent).
Я пытался свой ECS сделать, и кажется так и не нашёл баланса между удобством использования ECS и жёсткостью ограничений (которые позволили бы делать всякие крутые оптимизации).
Я пробовал ограничить, что ссылки из компонентов могут быть только на Entity и чтобы компонент какого-то типа мог быть только один. Для случаев когда входящего урона, например, может быть несколько, я делал компонент со списком элементов.
И даже в таком случае решение задачи типа «хотим сохранить снапшот мира со всеми ссылками» уже выглядело довольно костыльно.
Если да, то все nullable типы нужно проверять на null, поэтому я их помечаю так:
И там где идет обращение к таким полям, нужна проверка
2. Два одинаковых компонента нельзя. Точнее ты физически можешь, но добавиться только последний.
Код в энтити:
Я даже не представляю как организовывать потом системы если можно будет добавлять несколько одинаковых компонентов. Вижу только минусы
Что если, допустим, где-то осталась ссылка на компонент противника, а противник уже убит и даже убран с карты?
Ещё вопрос.
github.com/nakoff/Core-Godot/blob/main/Framework/Components/DamageComponent.cs
Получается, за один игровой цикл нельзя получить урон два раза?
Кстати по коду — я видел идею (и мне самому так понравилось) по папкам раскладывать по смыслу — типа система + компоненты которые она использует, а не так что в одной папке только системы и в другой только компоненты.
2. Делать так, что у тебя компонент одной сущности ссылается на компонент другой — странно
Максимум у меня компонент одной сущности может ссылаться на другую сущность в целом
3. Про урон, как ты сделаешь так и будет. Ты можешь прежде чем добавить новый компонент DamageReceive, проверить есть ли он уже и если есть просто инкрементировать его значение
4. Расскладывать по смыслу в ecs я точно не буду, я не представляю как и зачем.
К примеру MoveSystem использует компоненты MoveComponent и TransformComponent, но эти же компоненты используют еще куча других систем, как ты будешь группировать?
Ну и во-вторых, это репозиторий не проекта, а фреймворка. Сюда я выношу код, который можно будет испльзовать в других проектах. Я например могу потом заюзать компонент MoveComponent, но систему не брать, а написать другую.
В проекте я уже группирую скрипты внутри компонентов/систем
И я пытался код более абстрактным и эффективным сделать, получилась какая-то неудобная фигня.
У тебя многое по-другому сделано, при чтении была куча wtf-моментов, но интересно получилось. Например, у тебя просто массив фильтров и кеш объектов.
Я вместо этого хранил хеш-таблички entity->T, а когда вызывался фильтр, например, для двух компонент, я просто брал две хеш-таблички, выбирал ту что меньше, итерировался по её объектам и проверял наличие вторых.
Как итог у меня было почти бесплатное добавление-удаление компонентов (просто поменять одно значение в табличке), но не такая эффективная итерация по объектам. А у тебя наоборот — итерация вроде эффективная, но добавление компонента или фильтра требует обновлять кеш.
Добаление компонентов не так часто происходит, а вот итерация каждый божий кадр
К тому же я кажется эту возможность еще нигде не использовал