От ковров перейдем к серьезным вещам. Мы уже рассказали про ECS, какие есть фреймворки для Unity и почему написали свой (со списком можно ознакомиться в конце статьи). А сейчас остановимся на конкретных примерах, как используем ECS в нашем новом мобильном PvP-шутере и как реализуем игровые фичи. Отмечу, что применяем эту архитектуру мы только для симуляции мира на сервере и системы предсказания на клиенте. Визуализация и рендер объектов реализованы с помощью MPV-паттерна — но сегодня не об этом.
Архитектура 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 можно почитать тут:
- Как и почему мы написали свой ECS.
- Как мы отлаживаем в браузере самописный ECS на игровом сервере.
- Как ECS, C# Job System и SRP меняют подход к архитектуре.
- Unity ECS.
- Unity, ECS и все-все-все. Хорошая статья о ECS в примерах.
Автор: Алексей Дюдя