
От ковров перейдем к серьезным вещам. Мы уже рассказали про ECS, какие есть фреймворки для Unity и почему написали свой (со списком можно ознакомиться в конце статьи). А сейчас остановимся на конкретных примерах, как используем ECS в нашем новом мобильном PvP-шутере и как реализуем игровые фичи. Отмечу, что применяем эту архитектуру мы только для симуляции мира на сервере и системы предсказания на клиенте. Визуализация и рендер объектов реализованы с помощью MVP-паттерна — но сегодня не об этом.
Архитектура ECS является Data-oriented, все данные игрового мира хранятся в так называемом GameState и представляют собой список сущностей (entities) с некоторыми компонентами (components) на каждой из них. Набор компонентов определяет поведение объекта. А логика поведения компонентов сосредоточена в системах.
Геймстейт в нашей ECS состоит из двух частей: RuleBook и WorldState. RuleBook — это набор компонентов, которые не меняются в течение матча. Там хранятся все статические данные (характеристики оружия/персонажей, составы команд) и отправляются на клиент всего один раз — при авторизации на гейм-сервере.
Рассмотрим простой пример: спавн персонажа и его перемещение в 2D-пространстве с помощью двух джойстиков. Для начала объявим компоненты.
Этот определяет игрока и необходим для визуализации персонажа:
[Component] public class Player { }
Следующий компонент — запрос на создание нового персонажа. Он содержит два поля: время спавна персонажа (в тиках) и его ID:
[Component] public class PlayerSpawnRequest { public int SpawnTime; public unit PlayerId; }
Компонент ориентации объекта в пространстве:
[Component] public class Transform { public Vector2 Position; public float Rotation; }
Компонент, хранящий текущую скорость объекта:
[Component] public class Movement { public Vector2 Velocity; public float RotateToAngle; }
Компонент, хранящий инпут игрока (вектор джойстика движения и вектор джойстика вращения персонажа):
[Component] public class Input { public Vector2 MoveVector; public Vector2 RotateVector; }
Компонент со статическими характеристиками персонажа (он будет храниться в RuleBook, так как это базовая характеристика и не изменяется в течение игровой сессии):
[Component] public class PlayerStats { public float MoveSpeed; }
При декомпозиции фичи на системы мы часто руководствуемся принципом единственной ответственности (single responsibility principle): каждая система должна выполнять одну и только одну функцию.
Фичи могут состоять из нескольких систем. Начнем с определения системы спавна персонажа. Система проходит по всем запросам на создание персонажа в геймстейте и если текущее время мира совпадает с требуемым — создает нову�� сущность и прикрепляет к ней компоненты, определяющие игрока: Player, Transform, Movement.
public class SpawnPlayerSystem : ExecutableSystem { public override void Execute(GameState gs) { var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.SpawnAvatarRequest); foreach (var avatarRequest in gs.WorldState.SpawnAvatarRequest) { if (avatarRequest.Value.SpawnTime == gs.Time) { // create new entity with player ID var playerEntity = gs.WorldState.CreateEntity(avatarRequest.Value.PlayerId); // add components to determinate player behaviour playerEntity.AddPlayer(); playerEntity.AddTransform(Vector2.zero, 0); playerEntity.AddMovement(Vector2.zero, 0); // delete player spawn request deleter.Delete(avatarRequest.Key); } } } }
Теперь рассмотрим движение игрока по джойстику. Нам понадобится система, которая будет обрабатывать инпут. Она проходит по всем компонентам инпута, рассчитывает скорость игрока (стоит он или двигается) и преобразует вектор джойстика поворота в угол вращения:
MovementControlSystem public class MovementControlSystem : ExecutableSystem { public override void Execute(GameState gs) { var playerStats = gs.RuleBook.PlayerStats[1]; foreach (var pair in gs.Input) { var movement = gs.WorldState.Movement[pair.Key]; movement.Velocity = pair.Value.MoveVector.normalized * playerStats.MoveSpeed; movement.RotateToAngle = Math.Atan2(pair.Value.RotateVector.y, pair.Value.RotateVector.x); } } }
Следующая — система движения:
public class MovementSystem : ExecutableSystem { public override void Execute(GameState gs) { foreach (var pair in gs.WorldState.Movement) { var transform = gs.WorldState.Transform[pair.Key]; transform.Position += pair.Value.Velocity * GameState.TickDurationSec; } } }
Система, отвечающая за поворот объекта:
public class RotationSystem : ExecutableSystem { public override void Execute(GameState gs) { foreach (var pair in gs.WorldState.Movement) { var transform = gs.WorldState.Transform[pair.Key]; transform.Angle = pair.Value.RotateToAngle; } } }
Системы MovementSystem и RotationSystem работают только с компонентами Transform и Movement. Они независимы от сущности игрока. Если в нашей игре появятся другие сущности с компонентами Movement и Transform, то логика перемещения также будет работать с ними.
Для примера добавим аптечку, которая будет двигаться по прямой вдоль спавна и при подборе восполнять здоровье персонажа. Объявим компоненты:
[Component] public class Health { public uint CurrentHealth; public uint MaxHealth; } [Component] public class HealthPowerUp { public uint NextChangeDirection; } [Component] public class HealthPowerUpSpawnRequest { public uint SpawnRequest; } [Component] public class HealthPowerUpStats { public float HealthRestorePercent; public float MoveSpeed; public float SecondsToChangeDirection; public float PickupRadius; public float TimeToSpawn; }
Модифицируем компонент статов персонажа, добавив туда максимальное количество жизней:
[Component] public class PlayerStats { public float MoveSpeed; public uint MaxHealth; }
Теперь модифицируем систему спавна персонажа, чтобы персонаж появлялся с максимальным здоровьем:
public class SpawnPlayerSystem : ExecutableSystem { public override void Execute(GameState gs) { var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.SpawnAvatarRequest); var playerStats = gs.RuleBook.PlayerStats[1]; foreach (var avatarRequest in gs.WorldState.SpawnAvatarRequest) { if (avatarRequest.Value.SpawnTime <= gs.Time) { // create new entity with player ID var playerEntity = gs.WorldState.CreateEntity(avatarRequest.Value.PlayerId); // add components to determinate player behaviour playerEntity.AddPlayer(); playerEntity.AddTransform(Vector2.zero, 0); playerEntity.AddMovement(Vector2.zero, 0); playerEntity.AddHealth(playerStats.MaxHealth, playerStats.MaxHealth); // delete player spawn request deleter.Delete(avatarRequest.Key); } } } }
Затем объявляем систему спавна наших аптечек:
public class SpawnHealthPowerUpSystem : ExecutableSystem { public override void Execute(GameState gs) { var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.HealthPowerUpSpawnRequest); var healthPowerUpStats = gs.RoolBook.healthPowerUpStats[1]; foreach (var spawnRequest in gs.WorldState.HealthPowerUpSpawnRequest) { // create new entity var powerUpEntity = gs.WorldState.CreateEntity(); // add components to determine healthPowerUp behaviour powerUpEntity.AddHealthPowerUp((uint)(healthPowerUpStats.SecondsToChangeDirection * GameState.Hz)); playerEntity.AddTransform(Vector2.zero, 0); playerEntity.AddMovement(healthPowerUpStats.MoveSpeed, 0); // delete player spawn request deleter.Delete(spawnRequest.Key); } } }
И систему изменения скорости движения аптечки. Для упрощения, аптечка будет менять направление движения каждые несколько секунд:
public class HealthPowerUpMovementSystem : ExecutableSystem { public override void Execute(GameState gs) { var healthPowerUpStats = gs.RoolBook.healthPowerUpStats[1]; foreach (var pair in gs.WorldState.HealthPowerUp) { var movement = gs.WorldState.Movement[pair.Key]; if(pair.Value.NextChangeDirection <= gs.Time) { pair.Value.NextChangeDirection = (uint) (healthPowerUpStats.SecondsToChangeDirection * GameState.Hz); movement.Velocity *= -1; } } } }
Так как мы уже объявили MovementSystem для перемещения объектов в игре, нам понадобится только система HealthPowerUpMovementSystem для изменения вектора скорости движения, каждые N секунд.
Теперь допиливаем подбор аптечки и начисление HP персонажу. Нам понадобится еще один вспомогательный компонент для хранения количества жизней, которые получит персонаж после подбора аптечки.
[Component] public class HealthToAdd { public int Health; public Entity Target; }
И компонент для удаления нашего поверапа:
[Component] public class DeleteHealthPowerUpRequest { }
Пишем систему, обрабатывающую подбор аптечки:
public class HealthPowerUpPickUpSystem : ExecutableSystem { public override void Execute(GameState gs) { var healthPowerUpStats = gs.RoolBook.healthPowerUpStats[1]; foreach(var powerUpPair in gs.WorldState.HealthPowerUp) { var powerUpTransform = gs.WorldState.Transform[powerUpPair.Key]; foreach(var playerPair in gs.WorldState.Player) { var playerTransform = gs.WorldState.Transform[playerPair.Key]; var distance = Vector2.Distance(powerUpTransform.Position, playerTransform.Position) if(distance < healthPowerUpStats.PickupRadius) { var healthToAdd = gs.WorldState.Health[playerPair.Key].MaxHealth * healthPowerUpStats.HealthRestorePercent; var entity = gs.WorldState.CreateEntity(); entity.AddHealthToAdd(healthToAdd, gs.WorldState.Player[playerPair.Key]); var powerUpEnity = gs.WorldState[powerUpPair.Key]; powerUpEnity.AddDeleteHealthPowerUpRequest(); break; } } } } }
Система проходит по всем активным поверапам и рассчитывает расстояние до игрока. Если какой-либо игрок находится в радиусе подбора, система создает два компонента-запроса:
HealthToAdd — «запрос» на добавление жизней персонажу;
DeleteHealthPowerUpRequest — «запрос» на удаление аптечки.
Почему не добавить нужное количество жизней в этой же системе? Мы исходим из того, что игрок получает HP не только от аптечек, но и из других источников. В этом случае целесообразнее разделить системы подбора аптечки и систему начисления жизней персонажа. К тому же это больше соответствует Single Responsibility Principle.
Реализуем систему начисления жизней персонажу:
public class HealingSystem : ExecutableSystem { public override void Execute(GameState gs) { var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.HealthToAdd); foreach(var healtToAddPair in gs.WorldState.HealthToAdd) { var healthToAdd = healtToAddPair.Value.Health; var health = healtToAddPair.Value.Target.Health; health.CurrentHealth += healthToAdd; health.CurrentHealth = Mathf.Clamp(health.CurrentHealth, 0, health.MaxHealth); deleter.Delete(healtToAddPair.Key); } } }
Система проходится по всем компонентам HealthToAdd, начисляет нужное количество жизней в компонент Health у целевой сущности Target. Данная сущность ничего не знает о источнике и целевом объекте и довольно универсальная. Эту систему можно использовать не только для начисл��ния жизней персонажу, но для любых объектов, которые предполагают наличие жизней и их регенерацию.
Для реализации фичи с аптечками осталось добавить последнюю систему: систему удаления аптечки после ее подбора.
public class DeleteHealthPowerUpSystem : ExecutableSystem { public override void Execute(GameState gs) { var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.DeleteHealthPowerUpReques); foreach(var healthRequest in gs.WorldState.DeleteHealthPowerUpReques) { var id = healthRequest.Key; gs.WorldState.DelHealthPowerUp(id); gs.WorldState.DelTransform(id); gs.WorldState.DelMovement(id); deleter.Delete(id); } } }
В системе HealthPowerUpPickUpSystem создается запрос на удаление аптечки. Система DeleteHealthPowerUpSystem проходит по всем таким запросам и удаляет все компоненты, принадлежащие сущности аптечки.
Готово. Все системы из наших примеров реализованы. Есть один момент работы с ECS — все системы выполняются последовательно и этот порядок важен.
В нашем примере порядок систем следующий:
_systems = new List<ExecutableSystem> { new SpawnPlayerSystem(), new SpawnHealthPowerUpSystem(), new MovementControlSystem(), new HealthPowerUpMovementSystem(), new MovementSystem(), new RotationSystem(), new HealthPowerUpPickUpSystem(), new HealingSystem(), new DeleteHealthPowerUpSystem() };
В общем случае первыми идут системы, отвечающие за создание новых сущностей и компонентов. Затем системы обработки и в конце — системы удаления и очистки.
При должной декомпозиции ECS обладает большой гибкостью. Да, наша реализация не идеальна, но позволяет имплементировать фичи в сжатые сроки, а также обладает хорошей производительностью на современных мобильных устройствах. Еще об ECS можно почитать тут:
