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

Игровые фичи с помощью 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 можно почитать тут:


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Векторные представления товаров, или еще одно применение модели Word2Vec

Когда видов товаров тоже много, решить задачу помогает модель Word2Vec. Каждый день полтора миллиона людей ищут на Ozon самые разные товары, и к каждому из них сервис должен подбирать похожие (если пылесос все-таки нужен помощней) или сопутствующие (если к поющему ...

[Перевод] Внутренняя и внешняя линковка в C++

Всем добрый день! Надеемся, что она будет полезна и интересна для вас, как и нашим слушателям. Представляем вам перевод интересной статьи, который подготовили для вас рамках курса «Разработчик C++». Поехали. Хотите узнать, для чего используется ключевое слово extern, или как ...