Saturn VI

Перенос игры на ECS

16 февраля 2025

Рассказываю о том, как и зачем я перевёл игру с компонентно-ориентированного подхода на ECS, что получилось, и какие я сделал выводы.

Узнать больше про ECS (Entity Component System) можно по ссылке

Контекст

Последний год, в свободное от работы время, я занимался разработкой RTS-игры в качестве хобби. Работаю в Unity.

У меня довольно большой бэкграунд разработки разных RTS-проектов, и мне захотелось объединить весь полученный опыт. Решил попробовать собрать с нуля унифицированную основу для игры.

Цель: ускорение прототипирования и разработки игр в жанре RTS.

Понимая, что требования в каждом проекте могут радикально отличаться, к унификации я подошёл без фанатизма. Основная задача: сделать так, чтобы эта основа подходила для моих целей. Обычно, это классический геймплей в стиле Warcraft 3 или Command & Conquer.

Изначально, я вёл разработку по привычной мне схеме:

  1. Игровые сущности собираются из небольших MonoBehaviour-компонентов, которые умеют друг с другом комбинироваться без поломок.
  2. Конфигурация игры производится с помощью ScriptableObject-ассетов и CSV-конфигов (Google-таблиц).

И всё шло неплохо, но...

Почему решил перейти на ECS

Со временем начал замечать, что всё больше и больше игровой логики у меня объединяется в скрипты, которые проходят итерацией по всем сущностям одного типа, применяя к ним различную обработку поведения.

Помимо этого, работать со скриптами логики, написанными в обычном MonoBehaviour-подходе, стало тяжелее - возросло количество взаимодействий игровых механик.

А на префабах игровых юнитов скопилось большое количество компонентов. Префаб на монобехах

Пример, как может выглядеть итоговый префаб из другого, более старого RTS-проекта

Где-то в этот момент я решился на радикальный шаг - перевести всю геймплейную часть игры на ECS, и тем самым привести архитектуру игры к единому виду.

Лирическое отступление: к ECS я отношусь немного скептически - считаю, что иногда его тяжело применять из-за специфики жанра игры. Возможно, мне не хватает опыта, но личное предпочтение раньше находилось в стороне от данного подхода.
Однако, текущая игра - как раз тот случай, когда ECS ложится хорошо: много сущностей со схожим поведением.

К счастью, это мой собственный проект, который разрабатывается исключительно из интереса, а не с целью получить готовый продукт, поэтому решение о переходе на ECS не стало для меня проблемой.

Как это происходило

Основной проблемой стало количество уже написанной игровой логики - полноценный геймплей RTS с управлением юнитами, сражениями в ближнем и дальнем бою, строительством, баффами, и т. д. Почти всё на монобехах. Переносить много.

Более того, я не захотел брать готовый ECS-фреймворк. Не, ну а чо? Раз хочу перейти на ECS - надо хорошо понять его изнутри.

Так родился мой самописный ECS-фреймворк (если можно так выразиться), минималистичный настолько, что не имеет даже фильтров - как оказалось в ходе переноса, весь мой геймплей вполне собирается на ECS без них.

Файлы ECS

Все файлы, относящиеся к моему ECS и небольшому DI, тоже самописному

Перенос с параллельной разработкой ECS занял около недели парт-тайма, и даже оказался не сильно сложным - если изначально придерживаешься компоненто-ориентированного подхода и минимума наследований, то всё довольно хорошо ложится на ECS. Ну и жанр распологает :)

Результаты

Ещё не закончив, я стал замечать, что логика игры теперь проще воспринимается, стала более структурированной. Появился явный порядок выполнения - с монобехами это труднее контролировать.

Порядок выполнения систем на ECS

Пример одной из небольших систем для создания эффектов на юнитах при появлении соответствующего события-запроса. Для подобных событий использую постфикс Request, чтобы отличить их от остальных.

public class AddFxsSystem : BaseSystem, IUpdateSystem
{
	[Inject] ComponentsPool<Unit> units; // использую простой DI для кэширования некоторых полей при запуске игры
	[Inject] ComponentsPool<AddFxRequest> addFxsRequests;

	public void Update()
	{
		foreach (var entity in addFxsRequests)
		{
			if (!units.TryGet(entity, out var unit))
				continue;
	
			var request = addFxsRequests.Get(entity);

			Effect.TryCreate(request.FxTemplate, request.Position, request.Rotation, unit.Transform, req.Lifetime);
		}

		addFxsRequests.Clear();
	}
}

После завершения переноса на ECS, префабы игровых сущностей стали очень легковесными, и, по большей части, превратились в View-префабы (т. е. для отображения визуальной части).

В них остались только:

  • Скрипты-монобехи для настройки ссылок на юнитевские компоненты (Transform, Collider и т. д.). ECS берёт из них инфу с чем надо работать. Игровой логики не содержат
  • Скрипты для визуальных эффектов - пока не увидел смысла переносить в ECS некоторые эффекты. Игровой логики не содержат
  • Unity-компоненты, например, Collider - используя физику Unity, от них никуда не деться. Ну и компоненты типа Transform, Animator или Renderer - тоже на месте
  • Скрипты для проброса инфы от движка в ECS (например, ивенты коллизий по типу OnTriggerEnter). Такого почти нет - многие расчёты у меня проводятся без использования физики

Префаб после изменений

Префаб юнита после перехода на ECS

Так же получил небольшой буст производительности из-за более эффективной обработки большого кол-ва сущностей (RTS всё-таки). Это не было моей целью, но приятно.

Проблемы

Не весь геймплей получилось перевести на ECS бесшовно.

Система способностей

Основным проблемным местом стала система способностей. Её изначально я сделал таким образом, что каждая абилка - это, в сущности, инстанс ScriptableObject-ассета, в котором крутится логика обработки способности. И уникальных способностей может быть довольно много. Их конструктор я реализовал с помощью SerializeReference.

Конструктор абилок

Конструктор способностей. Настройка производится через Inspector, собирается из готовых механик

Такой комбайн перевести на ECS, по ощущениям, заняло бы столько же времени, сколько ушло на всё остальное. Помимо этого, показалось нецелесообразным плодить множество разных систем только под обработкууникальной логики различных способностей.

Сейчас нахожусь в процессе размышлений, как можно это улучшить.

События

Вторым проблемным местом, как я и ожидал, стали события. В ECS нельзя пользоваться классическим event из шарпа, потому что это ломает весь смысл, ломая порядок выполнения.

Одно из предлагаемых ECS решений - использовать компоненты-события, которые прикреплены к Entity до нужного момента. К счастью, это покрывает большинство ситуаций, где хочется использовать события.

attackedEvents.Add(e, new AttackedEvent(shootPosition));

Пример отправки события

if (attackedEvents.TryGet(entity, out var eventData))
{
	Effect.TryCreate(unit.Data.AttackFx, eventData.shootPosition, out var fx);
}

Пример чтения события

Но использовать такой подход можно не везде. Например, часть используемых мной готовых решений, "обёрнутых" в ECS, работают внутри в обычном ООП-подходе.

Пример событий

Они используются как сервисы, взаимодействующие с ECS, поэтому наличие там каких-то своих событий в целом ок, но мне всё же пришлось из некоторых систем подвязаться к их событиям, иначе они не заработали бы.

Каждая такая "подвязка" не выходит за рамки одной системы, поэтому серьёзных проблем не создаёт. В идеале избавиться от таких мест, или понять, как правильно с этим работать. Пока не понял.

Система модификаторов

Частично вне ECS у меня работает и система динамических модификаторов.

В чём суть системы? Если предельно просто, это - Dictionary<string, float>, словарь динамических float-полей для игровых объектов (пример на монобехах).

Максимальное здоровье, броня, урон и т. д. - всё с возможностью собирать финальное числовое значение с учётом баффов/дебаффов игровых объектов.

Словарь этот находится внутри ECS-компонента, но кажется, будто в ECS это надо делать как-то иначе :)

Выводы

В какой-то момент переноса мне показалось, что зря это затеял и только трачу время. Однако итог меня порадовал.

Работать с проектом стало приятнее:

  • Новые фичи легче добавлять, старые - легче вырезать
  • Кодовая база более читабельная
  • Префабы больше не содержат логику
  • Понятный порядок выполнения кода

Теперь кажется, что зря не решался на это раньше, в том числе в предыдущих проектах. В паре legacy-проектов подобный ECS мне сейчас сильно пригодился бы, но там уже поздно что-то менять.

Что дальше

По ощущениям, допил и расширение функционалом своего ECS-решения - не то, что мне дальше интересно. Поэтому, думаю всё же присмотреться к готовым минималистичным ECS-фреймворкам. Например, что-то из разработок Leopotam, в частности, интересен новый LeoEcsProto, с которым я ранее не работал.

Такая миграция не потребует особых изменений, поскольку моя реализация ECS по смыслу вышла довольно близко к фрейму LeoEcsLite, который, в свою очередь, является предшественником LeoEcsProto.

Если позже мне будет, что ещё сказать о принятом решении перевести игру на ECS, или же про сам ECS - думаю, напишу ещё одну статью.

Обсудить или задать можно в Telegram-чате канала :)