Хабрахабр

Игровые фичи с помощью ECS: добавляем в шутер аптечки

Мы уже рассказали про ECS, какие есть фреймворки для Unity и почему написали свой (со списком можно ознакомиться в конце статьи). От ковров перейдем к серьезным вещам. Отмечу, что применяем эту архитектуру мы только для симуляции мира на сервере и системы предсказания на клиенте. А сейчас остановимся на конкретных примерах, как используем ECS в нашем новом мобильном PvP-шутере и как реализуем игровые фичи. Визуализация и рендер объектов реализованы с помощью MPV-паттерна — но сегодня не об этом.

Набор компонентов определяет поведение объекта. Архитектура ECS является Data-oriented, все данные игрового мира хранятся в так называемом GameState и представляют собой список сущностей (entities) с некоторыми компонентами (components) на каждой из них. А логика поведения компонентов сосредоточена в системах.

RuleBook — это набор компонентов, которые не меняются в течение матча. Геймстейт в нашей ECS состоит из двух частей: RuleBook и WorldState. Там хранятся все статические данные (характеристики оружия/персонажей, составы команд) и отправляются на клиент всего один раз — при авторизации на гейм-сервере.

Для начала объявим компоненты. Рассмотрим простой пример: спавн персонажа и его перемещение в 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 } } }

Теперь рассмотрим движение игрока по джойстику. Нам понадобится система, которая будет обрабатывать инпут. Она проходит по всем компонентам инпута, рассчитывает скорость игрока (стоит он или двигается) и преобразует вектор джойстика поворота в угол вращения:

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 можно почитать тут:

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть