Что такое ECS на примере
Кратко я уже описывал, что такое Entity Component System, и на Хабре есть статьи про ECS (в основном, правда, переводы статей — смотрите мой обзор самых интересных из них в конце статьи, в качестве бонуса). А сегодня расскажу, как используем ECS мы — на примере нашего кода.
На схеме выше описаны сущность Player, её компоненты и их данные, и системы, которые работают с игроком и его компонентами. Ключевым объектом на схеме является игрок:
- может перемещаться в пространстве — компоненты Transform и Movement, система MoveSystem;
- имеет некоторое кол-во здоровья и может погибнуть — компонент Health, Damage, система DamageSystem;
- после смерти появляется на точке возрождения (respawn) — компонент Transform для положения, система RespawnSystem;
- может быть неуязвимым — компонент Invincible.
Опишем это кодом. Для начала заведем интерфейсы для компонентов и систем. У компонентов могут быть общие вспомогательные методы, у системы — всего один метод Execute, которому на вход на обработку подается состояние мира:
public interface IComponent
{
// <вспомогательные методы>
}
public interface ISystem
{
void Execute(GameState gs);
}
Для компонентов мы создаем классы-заготовки, которые используются нашим генератором кода для преобразования их в реально используемый код компонентов. Заведем заготовки для Health, Damage и Invincible (для остальных компонентов будет похоже).
[Component]
public class Health
{
[Max(1000)] // максимальное кол-во жизней 1000
public int Hp; // кол-во жизней игрока
public Health(int hp) {}
}
[Component]
public class Damage
{
[DontSend] // не посылать этот параметр по сети, клиенту не обязательно это знать
public uint Amount; // кол-во урона
public Entity Victim; // кому нанесен урон
public Entity Source; // кто нанес урон
public Damage(uint amount, Entity victim, Entity source) {}
}
[Component]
public class Invincible // не содержит данных, индикатор того, что игрок неуязвим
{
}
Компоненты определяют состояние мира, поэтому содержат только данные, без методов. При этом в Invincible данных нет, он используется в логике как признак неуязвимости — если у сущности игрока есть этот компонент, значит игрок сейчас неуязвим.
Атрибут Component используется генератором для нахождения классов-заготовок для компонентов. Атрибуты Max и DontSend нужны как подсказки при сериализации и уменьшения размера состояния мира, передаваемого по сети или сохраняемого на диск. В данном случае сервер не будет сериализовать поле Amount и посылать его по сети (потому что клиенты не используют этот параметр, он нужен только на сервере). А поле Hp можно хорошо упаковать в несколько бит, учитывая максимальное значение здоровья.
У нас также есть класс-заготовка Entity, куда мы добавляем информацию о всех возможных компонентах у любой сущности, а генератор уже создаст из него реальный класс:
public class Entity
{
public Health Health;
public Damage Damage;
public Invincible Invincible;
// ... <другие компоненты>
}
После этого наш генератор создаст код классов-компонентов Health, Damage и Invincible, которые уже будут использоваться в игровой логике:
public sealed class Health : IComponent
{
public int Hp;
public void Reset()
{
Hp = default(int);
}
// ... <другие вспомогательные методы>
}
public sealed class Damage : IComponent
{
public int Amount;
public Entity Victim;
public Entity Source;
public void Reset()
{
Amount = default(int);
Victim = default(Entity);
Source = default(Entity);
}
// ... <другие вспомогательные методы>
}
public sealed class Invincible : IComponent
{
}
Как видите, в классах остались данные и добавились методы, например, Reset. Он нужен для оптимизации и переиспользования компонентов в пулах. Другие методы вспомогательные, не содержат бизнес-логику — их не буду приводить для краткости кода.
Также будет сгенерирован класс для состояния мира, который содержит список всех компонентов и сущностей:
public sealed class GameState
{
// компоненты
public Table<Movement> Movements;
public Table<Health> Healths;
public Table<Damage> Damages;
public Table<Transform> Transforms;
public Table<Invincible> Invincibles;
// вспомогательные методы
public Entity CreateEntity() { /* <реализация> */ }
public void Copy(GameState gs2) { /* <реализация> */ }
public Entity this[uint id] { /* <реализация> */ }
// ... <другие сгенерированные члены класса>
}
И наконец, сгенерированный код для Entity:
public sealed class Entity
{
public uint Id; // идентификатор сущности
public GameState GameState; // ссылка на объекты мира
// сгенерированные методы для удобства использования:
public Health Health
{
get { return GameState.Healths[Id]; }
}
public Damage Damage
{
get { return GameState.Damages[Id]; }
}
public Invincible Invincible
{
get { return GameState.Invincibles[Id]; }
}
// … доступ к другим компонентам
public Damage AddDamage()
{
return GameState.Damages.Insert(Id);
}
public Damage AddDamage(int total, Entity victim, Entity source)
{
var c = GameState.Damages.Insert(Id);
c.Amount = total;
c.Victim = victim;
c.Source = source;
return c;
}
public void DelDamage()
{
GameState.Damages.Delete(Id);
}
// … <сгенерированные члены класса для других компонентов>
}
Класс Entity — это, в сущности, лишь идентификатор компонента. Ссылка на объекты мира GameState используются лишь в вспомогательных методах для удобства написания кода бизнес-логики. Зная идентификатор компонента, мы можем использовать его для сериализации связей между сущностями, реализации ссылок в компонентах на другие сущности. Например, компонент Damage содержит ссылку на сущность Victim для определения, кому нанесен урон.
На этом сгенерированный код заканчивается. Вообще, генератор нам нужен, чтобы не писать каждый раз вспомогательные методы. Мы только описываем компоненты как данные, дальше всю работу делает генератор. Примеры вспомогательных методов:
- создание/удаление сущностей;
- добавить/удалить/скопировать компонент, получить к нему доступ, если он существует;
- сравнить два состояния мира;
- сериализовать состояние мира;
- дельта-компрессия;
- код веб-страницы или окна Unity для отображения состояния мира, сущностей, компонентов (см. подробности ниже);
- и др.
Перейдем к коду систем. Они определяют бизнес-логику. Напишем для примера код системы, которая начисляет урон игроку:
public sealed class DamageSystem : ISystem
{
void ISystem.Execute(GameState gs)
{
foreach (var damage in gs.Damages)
{
var invincible = damage.Victim.Invincible;
if (invincible != null) continue;
var health = damage.Victim.Health;
if (health == null) continue;
health.Hp -= damage.Amount;
}
}
}
Система проходится по всем компонентам Damage в мире и смотрит, есть ли на потенциально поврежденном игроке (Victim) компонент Invincible. Если он есть — игрок неуязвим, урон не начисляется. Далее получаем компонент Health жертвы и уменьшаем здоровье игрока на размер урона.
Рассмотрим ключевые особенности систем:
- Система — это обычно stateless-класс, не содержит никаких внутренних данных, не пытается сохранить их куда-то, кроме данных о мире, передаваемых извне.
- Cистемы обычно проходятся по всем компонентам определенного типа и работают с ними. Называются, обычно, по типу компонента (Damage > DamageSystem) или по действию, которые они осуществляют (RespawnSystem).
- Система реализует минимальную функциональность. Например, если пойти дальше, то после выполнения системы DamageSystem другая система RemoveDamageSystem удалит все компоненты Damage. На следующем тике еще одна система ApplyDamageSystem на основе стрельбы игрока может снова навесить компонент Damage с новым уроном. А далее система PlayerDeathSystem проверит здоровье игрока (Health.Hp) и, если оно меньше или равно 0, уничтожит все компоненты игрока, кроме Transform, и добавит компонент-флаг Dead.
Итого, получаем следующие классы и связи между ними:
Некоторые факты о ECS
У ECS есть свои плюсы и минусы, как подхода к разработке и способа представления мира игры, поэтому каждый сам для себя решает — использовать его или нет. Начнем с плюсов:
- Композиция против множественного наследования. В случае множественного наследования может наследоваться куча ненужного функционала. В случае ECS функционал появляется/исчезает при добавлении/удалении компонента.
- Разделение логики и данных. Возможность менять логику (менять системы, удалять/добавлять компоненты), не ломая данные. Т.е. можно в любой момент отключить группу систем, отвечающих за определенную функциональность, все остальное продолжит работать и это не затронет данные.
- Упрощается игровой цикл. Появляется один Update, а весь цикл разбивается на системы. Данные обрабатываются «потоком» в системе, независимо от движка (нет миллионов вызовов Update, как в Unity).
- Сущность не знает, какие классы на нее влияют (и не должна знать).
- Эффективное использования памяти. Это зависит от реализации ECS. Можно переиспользовать созданные объекты сущностей и компонент, используя пулы; можно использовать типы-значения для данных и хранить их в памяти рядом (Data locality).
- Проще тестировать, когда данные отделены от логики. Особенно если учесть, что логика — это небольшая система в несколько строк кода.
- Просмотр и редактирование состояния мира в реальном времени. Т.к. состояние мира это просто данные, мы написали тулзу, которая на веб-странице отображает все состояние мира в матче на сервере (а также сцену матча в 3D). Любой компонент любой сущности можно просмотреть, изменить, удалить. То же самое можно сделать в редакторе Unity для клиента.
А теперь минусы:
- Нужно учиться думать, проектировать и писать код по-другому. Думать в рамках сущностей, компонент и систем. Многие паттерны проектирования в ECS реализуются совсем по-другому (см. пример реализация паттерна Состояние (State) в одной из обзорных статей в конце).
- Больше кода. Спорно. С одной стороны, из-за того, что мы разбиваем логику на мелкие системы, вместо того, чтобы описать всю функциональность в одном классе, классов становится больше, но именно кода не намного больше.
- Порядок вызова систем влияет на работу всей игры. Обычно, системы зависимы друг от друга, порядок их выполнения задается списком и они выполняются в этом порядке. Например, сначала DamageSystem считает урон, затем RemoveDamageSystem удаляет компонент Damage. Если случайно поменять порядок, то все станет работать по-другому. В целом, это актуально и для обычного ООП-случая, если поменять порядок вызова методов, но в ECS ошибиться проще. Например, если часть логики работает на клиенте для prediction, то порядок должен быть такой же, как на сервере.
- Нужно как-то связывать данные и события логики с представлением. В случае с Unity у нас MVP:
— Model — GameState из ECS;
— View — у нас это исключительно стандартные MonoBehaviour-классы Unity (Renderer, Text и т.д.) и префабы;
— Presenter использует GameState для определения событий появления/исчезания сущностей, компонент и т.д., создает объекты Unity из префабов и меняет их в соответствии с изменением состояния мира.
А знаете ли вы, что:
- ECS — это не только про data locality. Для меня это больше парадигма программирования, паттерн, еще один способ проектирования игрового мира — назовите как угодно. Data locality — это лишь оптимизация.
- В Unity нет ECS! Часто на собеседовании в команду спрашиваешь кандидатов — а что вы знаете про ECS? Если не слышали, рассказываешь им, а они в ответ: «А, так это ж как в Unity, тогда знаю!». Но нет, это не как в движке Unity. Там данные и логика объединены в компоненте MonoBehaviour, а GameObject (если сравнивать с сущностью в ECS) обладает дополнительными данными — имя, место в иерархии и др. Разработчики Unity сейчас работают над нормальной реализацией ECS в движке и пока видится, что она будет хороша. Они наняли специалистов в этой области — надеюсь, получится круто.
Наши критерии выбора ECS-фреймворка
Когда мы решили делать игру на ECS, мы начали искать готовое решение и выписали требования к нему на основе опыта одного из разработчиков. И расписали, насколько существующие решения соответствуют нашим требованиям. Это было год назад, на текущий момент что-то могло уже измениться. В качестве решений мы рассматривали:
- Entitas
- Artemis C#
- Ash.NET
- ECS — наше собственное решение на момент, когда мы его задумывали. Т.е. наши предположения и хотелки, что мы можем сделать сами.
Мы составили таблицу для сравнения, куда я также включил наше текущее решение (обозначил его как ECS(now)):
Красный цвет — решение не поддерживает наше требование, оранжевый — поддерживает частично, зеленый — поддерживает полностью.
Для нас аналогией операций доступа к компонентам, поиска сущностей в ECS были операции в sql-базе данных. Поэтому мы использовали понятия типа table (таблица), join (операция соединения), indices (индексы) и т.д.
Распишем наши требования и насколько сторонние библиотеки и фреймворки им соответствовали:
- separate data sets (history, current, visual, static) — возможность отдельно получить и хранить состояния мира (например, текущее состояние для обработки, для отрисовки, история состояний и т.д.). Все из рассматриваемых решений поддерживали это требование.
- entity ID as integer — поддержка представления сущности ее идентификатором-числом. Нужно для передачи по сети и возможности связывать сущности в истории состояний. Ни в одном из рассматриваемых решений поддержки не было. Например, в Entitas сущность представлена полноценным объектом (как GameObject в Unity).
- join by ID O(N+M) — поддержка относительно быстрой выборки по компонентам двух типов. Например, когда нужно получить все сущности с компонентами типа Damage (допустим, их N штук) и Health (M штук) для расчета и нанесения урона. Была полная поддержка в Artemis; в Entitas и Ash.NET она быстрее O(N?), но медленнее O(N+M). Точнее оценку сейчас уже не помню.
- join by ID reference O(N+M) — то же самое, что выше, только когда в компоненте одной сущности есть ссылка на другую, и у последний нужно получить другой компонент (в нашем примере компонент Damage на вспомогательной сущности ссылается на сущность игрока Victim и оттуда нужно получить компонент Health). Не поддерживалось ни одним из рассмотренных решений.
- no query alloc — нет лишних аллокаций памяти при запросе компонентов и сущностей из состояния мира. В Entitas в определенных кейсах она была, но незначительная для нас.
- pool tables — хранение данных мира в пулах, возможность повторного использования памяти, аллокации только когда пул пустой. Была «какая-то» поддержка в Entitas и Artemis, полное отсутствие в Ash.NET.
- compare by ID (add, del) — встроенная поддержка событий создания/уничтожения сущностей и компонент по ID. Нужно для уровня отображения (View), чтобы показывать/скрывать объекты, проиграть анимации, эффекты. Не поддерживалось ни одним из рассмотренных решений.
- ? serialisation (quantisation, skip) — встроенная дельта-компрессия при сериализации состояния мира (например, для уменьшения размера пересылаемых по сети данных). «Из коробки» не поддерживалась ни в одном из решений.
- Interpolation — встроенный механизм интерполяции между состояниями мира. Ни одно из решений не поддерживало.
- reuse component type — возможность использовать один раз написанный тип компонента в разных типах сущностей. Поддерживал только Entitas.
- explicit order of systems — возможность задать свой порядок вызова систем. Все решения поддерживали.
- editor (unity/server) — поддержка просмотра и редактирования сущностей в реальном времени, как для клиента, так и для сервера. Entitas поддерживал возможность просматривать и редактировать сущности и компоненты только в редакторе Unity.
- fast copy/replace — возможность дешевого копирования/замены данных. Ни одно из решений не поддерживало.
- component as value type (struct) — компоненты как типы-значения. В принципе, хотелось на основе этого добиться хорошей производительности. Не поддерживалось ни одной из систем, везде были компоненты-классы.
Необязательные требования (ни одно из решений на тот момент их не поддерживало):
- indices — индексирование данных как в БД.
- composite keys — сложные ключи для быстрого доступа к данным (как в БД).
- integrity check — возможность проверки целостности данных в состоянии мира. Полезно при отладке.
- content-aware compression — лучшее сжатие данных на основе знания о природе данных. Например, если мы знаем, максимальный размер карты или максимальное кол-во объектов мира.
- types/systems limit — ограничение на кол-во типов компонент или систем. В Artemis на тот момент нельзя было создать больше 32 или 64 типов компонент и систем.
Как видно по таблице, самостоятельно мы хотели реализовать все требования, кроме необязательных. На деле на текущий момент мы не сделали:
- join by ID O(N+M) и join by ID reference O(N+M) — выборка по двум разным компонентам у нас до сих пор занимает O(N?) (фактически, вложенный цикл for). С другой стороны, сущностей и компонент на матч не так уж и много.
- compare by ID (add, del) — не понадобилось на уровне фреймворка. Это мы реализовали на уровне выше, в MVP.
- fast copy/replace и component as value type (struct) — в какой-то момент мы поняли, что работать с структурами будет не так удобно, как с классами, и остановились на классах — предпочли удобство разработки вместо лучшей производительности. Кстати говоря, разработчики Entitas поступили в итоге так же.
При этом мы все таки реализовали одно из изначально необязательных на наш взгляд требований:
- content-aware compression — за счет него нам удалось значительно (в десятки раз) уменьшить размер передаваемого по сети пакета. Для мобильных сетей передачи данных очень важно уместить размер пакета в MTU, чтобы «по дороге» его не разбивали на мелкие части, которые могут потеряться, дойти в другом порядке, и которые потом нужно будет собирать по частям. Например, в Photon, если размер данных не умещается в заданный в библиотеке MTU, он разбивает данные на пакеты и посылает их как reliable (с гарантированной доставкой), даже если вы сами «сверху» посылаете их как unreliable. Проверено с болью на собственном опыте.
Особенности нашей разработки на ECS
- Мы в ECS пишем исключительно бизнес-логику. Никакой работы с ресурсами, представлением и т.д. Так как код ECS-логики одновременно работает на клиенте в Unity и на сервере — он должен быть максимально независим от других уровней и модулей.
- Стараемся минимизировать компоненты и системы. Обычно на каждую новую задачу мы заводим новые компоненты и системы. Но иногда бывает, что модифицируем старые, добавляем в компоненты новые данные, а системы «раздуваем».
- В нашей реализации ECS нельзя добавить на одну сущность несколько компонентов одного типа. Поэтому, если в один тик игроку нанесли урон несколько раз (например, несколько противников), то обычно мы создаем на каждый урон новую сущность и добавляем на нее компонент Damage.
- Иногда, представлению недостаточно той информации, которая есть в GameState. Тогда приходится добавлять специальные компоненты или дополнительные данные, которые в логике не участвуют, но нужны представлению. Например, на сервере выстрел моментальный, живет один тик, а на клиенте визуально он дольше. Поэтому для клиента выстрелу добавляется параметр «время жизни выстрела».
- События/запросы мы реализуем за счет создания специальных компонент. Например, если игрок умер, мы вешаем на него компонент без данных Dead, что является событием для других систем и View-уровня о том, что игрок умер. Или если нам нужно заново возродить игрока на точке, мы создаем отдельную сущность с компонентом Respawn с дополнительной информацией кого возродить. Отдельная система RespawnSystem в самом начале игрового цикла проходится по этим компонентам и уже создает сущность игрока. Т.е. фактически первая сущность является запросом на создание второй.
- У нас есть специальные «singleton»-компоненты/сущности. Например, у нас есть сущность с ID=1, на которой висят специальные компоненты — настройки игры.
Бонус
В процессе решения — а нужна ли Хабру статья про ECS — я провел небольшое исследование. Как и обещал в начале, вот мой небольшой обзор статей по этой теме, а вы решайте, читать или нет:
- Unity, ECS и все-все-все — пока лучшая статья на Хабре про ECS на мой взгляд. Автор mopsicus отлично расписал, что такое ECS, с примерами. Также у нас с ним общая точка зрения: в Unity не ECS в классическом смысле, а некая пародия. Автор корректно объясняет почему. Расписаны преимущества «чистого» ECS перед реализацией в Unity. Еще автор указал ECS-библиотеки, о которых я раньше не знал: LeoECS, BrokenBricksECS, Svelto.ECS.
- Unity3D ECS и Job System — совсем свежая статья, неплохой обзор текущего состояния экспериментального ECS в Unity. Автор fstleo на примере показал, как работает Unity ECS, как фильтровать сущности по компонентам, в том числе провел эксперимент на производительность с кол-вом однотипных сущностей и показал в нем, для чего нужна JobSystem.
- Что такое Entity System Framework и зачем он нужен в геймдеве? — перевод статьи про Ash-фреймворк на ActionScript. Читать всем, кто хочет понять, как код и мышление эволюционирует от стандартного OOP-подхода к ECS-подходу.
- Паттерн конечные автоматы для Ash Entity System фреймворк — перевод о том, как реализовать паттерн FSM и State в ECS — в качестве состояний приходится использовать компоненты, и они сменяют друг друга при переходе.
- Шаблон проектирования Entity-Component-System — реализация и пример игры — перевод статьи про самописную ECS на C++.
Комментарии (36)
Wituss
13.06.2018 14:41У меня на стыке между ECS, Multiplayer и Unity всегда только один вопрос: Как реализовать 3d физику в игре? Из прошлой статьи я видел, что у вас 2d Volatile Physics. С этим конечно гораздо проще. Как бы вы подошли к 3d физике если бы игра ее требовала?
fischer Автор
13.06.2018 15:01+1Точно так же подошли бы: поискали бы готовый движок 3d-физики, который бы мог вписаться в наши техтребования (например, C#, детерминизм/недетерминизм и тд). Или вы имеете ввиду, какие конкретные физические движки мы бы выбрали?
Wituss
13.06.2018 15:36Я пока еще без особой практики в сетевых играх поэтому меня вообще интересует как синхронизируется физика в играх. N раз в секунду синхронизировать все объекты? Это получается очень большой трафик, если физики в игре много. Детерминизм? Тогда движок должен быть детерминированным, а таких я сколько ни гуглил — не получалось найти. 2д движки видел, 3д не видел.
И еще как вы встраиваете физическое состояние в ECS модель? Получается за позицию объекта отвечает и ECS системы и физический движок. Плюс всякие OnTriggerEnter и другие события надо перенести в ECS.fischer Автор
13.06.2018 17:49Да, с детерминизмом 3d-физики пока проблемы. Но в нашем случае детерминизм не нужен. У нас клиент симулируют то же самое, что сервер (ну почти то же самое) — prediction, и если есть расхождения, откатывает на последнее валидное состояние с сервера — rollback (Скоро выйдет наша статья про эту часть). Расхождения из-за недетерминизма могут быть, но в наших размахах несущественные.
fischer Автор
13.06.2018 17:51Мы синхронизируем вcё состояние мира, и это действительно много (у нас было 5Kb). Но мы запилили дельта-компрессию, и теперь трафик небольшой (в среднем 300 байт на стейт).
marsermd
13.06.2018 18:12Вот вы говорите что у вас мобильная игра, а потом говорите про дельта-компрессию.
Как вы справляетесь с потерей пакетов? Избыточно перепосылаете состояние мира несколько раз?fischer Автор
13.06.2018 18:49С вводом так. Подписываем ввод номером тика, и шлем по udp пачкой N последних, за счет этого вероятность доставки сильно повышается.
Обратно сервер получает от клиента номер последнего дошедшего состояния, и есть параметр, который говорит, насколько часто надо посылать полный стейт (который тем не менее хитро запакован, с учетом знаний об игре — content-aware compression).marsermd
14.06.2018 00:00Да, ввод понятно что дублируете:)
Т.е. если я правильно понял, при потере 1 пакета состояния мира, вы не можете применить состояние на клиенте в течение еще 1 RTT?
т.к. клиент должен отправить на сервер nack, и только после этого сервер перепошлет потеряный пакет?
fischer Автор
14.06.2018 12:19Ну тут только 2 варианта:
1. Клиент симулирует.
2. Интерполяция между дошедшими состояниями. Т.е. если дошли Состояние5 и Состояние7, а Состояния 6 еще нет, но уже нужно, оно получается с помощью интерполяции.marsermd
14.06.2018 12:25- Клиент симулирует и врагов? Т.е. наивная экстрополяция? Мы ведь не знаем ввода других игроков:)
- Мы же говорим про дельта-компрессию. т.е. если у вас есть S5 и дошла delta(S6,S7), вы не можете восстановить S7, т.к. у вас нет delta(S5,S6). Или под дельта-компрессией вы подразумеваете что-то другое?
fischer Автор
13.06.2018 17:54По связи физ. движка и ECS, в целом это выглядит так — на сервере есть специальные системы для физики, они создают специальные компоненты, к которым привязаны объекты из физической библиотеки. Отдельно в цикле обновляется “мир” физики, вместе с ним обновляются позиции/ориентация объектов, обрабатываются коллизии.
marsermd
13.06.2018 18:11Прям детерменизм нужен только если есть объекты которые очень редко обновляются (и то обычно они редко обновляются т.к. игрок их не видит, а значит и хрен с ним с недетерменизмом).
А так да, много раз в секунду отправляется все состояние мира.
marsermd
13.06.2018 14:47+1Скажите пожалуйста, а как вы применяете команды (пользовательский ввод) в системах?
Удобно ли пользоваться подходом "все есть компонент" и в случае с "событием" Dead? Или все же есть ощущение что не хватает более callback ориентированного подхода?
fischer Автор
13.06.2018 14:58+1В GameState есть сущности с компонентом UserInput от каждого игрока. Есть система, которая проходится по всем компонентам этого типа и обрабатывает ввод.
По поводу событий: в некоторых реализациях ECS («закрытых» пока для внешнего мира, поэтому тут не упомянул) я видел подходы с реализованными событиями. Мне лично понравилось и мы думаем над ними.
fstleo
13.06.2018 15:40Показалось странным, что Entity умеет вообще всё. Как вы различаете функциональность, например, объекта игрока и какого-нибудь интерактивного объекта (пикабл, дверь и т.д.)? Ведь у них у всех есть и Health, и AddDamage, и весь набор всевозможных компонентов.
И GameState с кучей таблиц разномастных компонентов, наследующих IComponent. Не пробовали собрать их в одну структуру с доступом по типу и id?fischer Автор
13.06.2018 15:56+1Это наша первая реализация, так что не все может быть идеально.
Формально мы не различаем типы сущностей. Для нас сущность может содержать любой компонент, хоть все сразу (но не более одного — особенность реализации). Фактически, системы в коде сами понимают, что это за сущность, по типу компонентов на ней. Например, на двери скорее всего будет компонент Door с ее параметрами и какой-нибудь Destructable, если ее можно разрушать.
По идее ECS все равно, что за объекты у вас в игре, вы сами решаете, каким образом их «обозначить» и нужно ли их как-то различать. Я видел примеры реализаций ECS как с различаемыми типами сущностей (где можно конкретно сказать, что это игрок, это оружие, это препятствие и у них могут быть только «вот эти» компоненты), так и с обобщенной сущностью, где понять, что это за сущность, можно только по ее компонентам (как реализовано у нас).fstleo
13.06.2018 17:15Кажется, я понял. Меня покоробили широкий функционал в GameState и Entity, завязанный на конкретные классы. Entity заведует и своим ID, и получением своих компонентов, и всей их функциональностью (урон, здоровье, вот это всё). Да, удобно, не спорю, и кодогенерация.
Может, будет удобней добавлять эту функциональность в Entity через методы расширения? Их можно писать/генерировать где-нибудь рядом с компонентом и добавлять или удалять вместе с ним же.fischer Автор
13.06.2018 17:52Да, сейчас у нас здоровенный класс Entity, и я думал о том, чтобы разбивать его либо на partial-классы, либо делать методы-расширения (extensions). Руки пока не дошли)
fstleo
13.06.2018 23:09Partial-класс выглядит для меня еще более неказисто. А методы-расширения можно раскидать по namespace (в стиле LINQ) или сборкам, отключать и подключать их в нужных местах. Например, namespace с компонентом Damage и соответствующими методами-расширения для Entity только в том месте, где оно используется, и не пролистывать десятки ненужных методов в автодополнении :)
hibissscus
13.06.2018 16:32Подскажите, что за редактор использовался для создания блок-схемы.
fischer Автор
13.06.2018 18:01Если вы про диаграмму связей, то это стандартные возможности Visual Studio.
Если про схему с ECS, то схему накидал от руки на листочке, а потом попросил дизайнера отрисовать в графическом редакторе)
Imp5
13.06.2018 17:31Скажите, а у вас в ECS все поля по умолчанию интерполируются?
fischer Автор
13.06.2018 17:41Нет, только те, что помечены специальным атрибутом. На данный момент это в основном положение и ориентация объектов в пространстве.
marsermd
13.06.2018 17:48Забавно кстати что вы это решили включить в ECS:)
Кажется что интерполяция, сериализация и ECS — три разные вещи, а у вас они вместе лежат.
Кстати, у меня вот какой вопрос:
Своего игрока, как известно, надо экстрополировать (ну т.е. client-side prediction).
А вражеских игроков надо интерполировать.
Как у вас это разделение реализовано в концепции систем?
А еще: при выстреле вы наверняка используете Lag Compensation. Для этого надо откатывать во времени назад всех, кроме стрелка. Так что тут аналогичный вопрос. Или вы откатываете только физику, а поля компонент оставляете теми же?
P.S. если кто-то не понимает о чем я тут говорю, тут можно прочитать.
fischer Автор
13.06.2018 17:57У нас изначально были определенные техтребования к решению, и хотелось, чтобы какие-то фичи уже были реализованы, например, интерполяция. Можно было бы ее напилить «сверху», но мы в итоге сошлись на плотной интеграции — и быстрее реализовать, и легче исправить)
marsermd
13.06.2018 18:14Ок, понятно.
Мы примерно из тех же соображений слили воедино сериализацию и дупликацию объектов:) Правда интерполяция у нас немного в стороне.korchoon
13.06.2018 20:37Читал ваш перевод статей G. Gambetta и делал схожую реализацию.
Скажите, когда на сервер приходит запоздалый выстрел, берется предыдущий от него стейт мира и на него заново накатываются инпуты + интерполируются позиции/ повороты перевычисляются последующие, вплоть до текущего стейта мира, который рассылается всем? Или какой-то менее расходный по ресурсам метод?marsermd
14.06.2018 00:10него заново накатываются инпуты
Нет-нет.
1) мир откатывается
2) проверяем, попал ли игрок во врага
3) мир возвращается в исходное состояние
4) попадание применяется (если оно было)
Мы много всяких прототипов писали, даже со сложными временными взаимодействиями, но никогда не надо было переприменять ввод на сервере.
korchoon
14.06.2018 11:02Спасибо за ценный совет.
Хотя с первого взгляда мне казалось, что перенакатывать инпут — честнее. Но это ведёт к проблемам (например откат смерти на клиенте)
fischer Автор
13.06.2018 17:59Да, у нас есть predication и rollback. Об этом я чуть-чуть писал в предыдущей статье, и скоро выйдет новая, где про это будет очень подробно.
Если кратко, на клиенте мы симулируем только локального игрока, остальных не трогаем, берем с сервера (т.е. игрок смотрит на мир в прошлом, а сам в нем в будущем).
Откат во времени есть, работает только на сервере. Подробнее об этом будет в статье.marsermd
13.06.2018 18:17Да, это мне все понятно. Непонятно только как системы отличают локального игрока и не локального:)
В общем ок, жду следующей статьи.
JoeBaker
14.06.2018 12:20Я просто балдею когда читаю код от прочих игр и РПГ-шек. Почти во всех чувствуеться некий определенный порядок и правильность решений.
mopsicus
А ваше решение не опенсорс случаем?) Хотелось бы поближе посмотреть
fischer Автор
Нет, внутреннее решение. Об опенсорсе пока речи не идет.