CyberGrad 2033 Архитектура: Ecs

Предыдущий пост: CyberGrad 2033

Сразу оговорюсь, данная реализация лишь внешне выглядит как 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 контейнером
Об этом как-нибудь в следйющий раз

9 комментариев

Igor
А как сделано удаление и ссылки из компонентов других Entity на удаляемую Entity? Особенно с учётом того, что у тебя компоненты могут ссылаться и на другие ноды (которые могут быть ещё и компонентами).

Просто я видел разные подходы и они все были со своими недостатками.

Ещё вопрос про компоненты. У тебя можно прицепить несколько компонент одного типа к одному объекту? Выглядит так что можно (раз в коде есть компонент типа DamageReceiveComponent).

Я пытался свой ECS сделать, и кажется так и не нашёл баланса между удобством использования ECS и жёсткостью ограничений (которые позволили бы делать всякие крутые оптимизации).
Я пробовал ограничить, что ссылки из компонентов могут быть только на Entity и чтобы компонент какого-то типа мог быть только один. Для случаев когда входящего урона, например, может быть несколько, я делал компонент со списком элементов.
И даже в таком случае решение задачи типа «хотим сохранить снапшот мира со всеми ссылками» уже выглядело довольно костыльно.
Комментарий отредактирован: 11 января 2025, 20:56
0
ant0n
1. Не совсем понял про удаление. Например у компонента есть ссылка но ноду и эта нода удалилась, так?
Если да, то все nullable типы нужно проверять на null, поэтому я их помечаю так:
[Export] public Node? Target = null;
И там где идет обращение к таким полям, нужна проверка

2. Два одинаковых компонента нельзя. Точнее ты физически можешь, но добавиться только последний.
Код в энтити:
public T AddComponent<T>(T component, bool attach = true) where T : EcsComponent
	{
		if (HasComponent<T>())
			RemoveComponent<T>();

Я даже не представляю как организовывать потом системы если можно будет добавлять несколько одинаковых компонентов. Вижу только минусы
1
Igor
Хм, а годот при удалении ноды Target автоматически занулит все ссылки на неё?

Что если, допустим, где-то осталась ссылка на компонент противника, а противник уже убит и даже убран с карты?

Ещё вопрос.
github.com/nakoff/Core-Godot/blob/main/Framework/Components/DamageComponent.cs
Получается, за один игровой цикл нельзя получить урон два раза?

Кстати по коду — я видел идею (и мне самому так понравилось) по папкам раскладывать по смыслу — типа система + компоненты которые она использует, а не так что в одной папке только системы и в другой только компоненты.
0
ant0n
1. Пока не проверял, вроде должен занулять (у меня пока с этим проблем не было). На крайний случай можно использовать IsInstanceValid() (примерно как-то так называется метод в годо)

2. Делать так, что у тебя компонент одной сущности ссылается на компонент другой — странно
Максимум у меня компонент одной сущности может ссылаться на другую сущность в целом

3. Про урон, как ты сделаешь так и будет. Ты можешь прежде чем добавить новый компонент DamageReceive, проверить есть ли он уже и если есть просто инкрементировать его значение

4. Расскладывать по смыслу в ecs я точно не буду, я не представляю как и зачем.
К примеру MoveSystem использует компоненты MoveComponent и TransformComponent, но эти же компоненты используют еще куча других систем, как ты будешь группировать?
Ну и во-вторых, это репозиторий не проекта, а фреймворка. Сюда я выношу код, который можно будет испльзовать в других проектах. Я например могу потом заюзать компонент MoveComponent, но систему не брать, а написать другую.

В проекте я уже группирую скрипты внутри компонентов/систем
Комментарий отредактирован: 12 января 2025, 10:28 (3 раза)
1
Igor
Я до этого прочитал цикл статей ECS back and forth и пробовал сделать примерно как там.

И я пытался код более абстрактным и эффективным сделать, получилась какая-то неудобная фигня.

У тебя многое по-другому сделано, при чтении была куча wtf-моментов, но интересно получилось. Например, у тебя просто массив фильтров и кеш объектов.

Я вместо этого хранил хеш-таблички entity->T, а когда вызывался фильтр, например, для двух компонент, я просто брал две хеш-таблички, выбирал ту что меньше, итерировался по её объектам и проверял наличие вторых.

Как итог у меня было почти бесплатное добавление-удаление компонентов (просто поменять одно значение в табличке), но не такая эффективная итерация по объектам. А у тебя наоборот — итерация вроде эффективная, но добавление компонента или фильтра требует обновлять кеш.
Комментарий отредактирован: 12 января 2025, 15:46
0
ant0n
Я наоборот не читал ничего, делал понаитию

А у тебя наоборот — итерация вроде эффективная, но добавление компонента или фильтра требует обновлять кеш
Добаление компонентов не так часто происходит, а вот итерация каждый божий кадр
0
ant0n
А где можно увидеть твою реализацию?
0
Igor
А, я кажется понял. Просто в моём подходе компоненты были тупые, а у тебя каждый компонент ещё знает его родительскую entity. Поэтому часть логики иначе сделана.
0
ant0n
То, что у компонента есть ссылка на свою энтити, не делает его умным )
К тому же я кажется эту возможность еще нигде не использовал
0