Vamos passar de
tapetes para coisas sérias. Já falamos sobre o ECS, quais estruturas existem para o Unity e por que eles criaram os seus próprios (você pode encontrar a lista no final do artigo). Agora, vamos nos debruçar sobre exemplos específicos de como usamos o ECS em nosso novo jogo de PvP para celular e como implementamos recursos de jogos. Observo que usamos essa arquitetura apenas para simular o mundo no servidor e o sistema de previsão no cliente. A visualização e a renderização de objetos são implementadas usando o padrão MVP - mas não sobre isso hoje.
A arquitetura do ECS é orientada a dados, todos os dados do mundo do jogo são armazenados no chamado GameState e é uma lista de entidades (entidades) com alguns componentes em cada uma delas. Um conjunto de componentes determina o comportamento de um objeto. E a lógica do comportamento dos componentes está concentrada nos sistemas.
O estado do jogo em nosso ECS consiste em duas partes: RuleBook e WorldState. O RuleBook é um conjunto de componentes que não são alterados durante uma partida. Todos os dados estáticos são armazenados lá (características das armas / personagens, composição das equipes) e enviados ao cliente apenas uma vez - durante a autorização no servidor do jogo.
Considere um exemplo simples: crie um personagem e mova-o em um espaço 2D usando dois joysticks. Primeiro, declare os componentes.
Isso define o jogador e é necessário para a visualização do personagem:
[Component] public class Player { }
O próximo componente é uma solicitação para criar um novo personagem. Ele contém dois campos: tempo de reprodução dos caracteres (em ticks) e seu ID:
[Component] public class PlayerSpawnRequest { public int SpawnTime; public unit PlayerId; }
O componente da orientação do objeto no espaço:
[Component] public class Transform { public Vector2 Position; public float Rotation; }
O componente que armazena a velocidade atual do objeto:
[Component] public class Movement { public Vector2 Velocity; public float RotateToAngle; }
O componente que armazena a entrada do jogador (vetor de joystick de movimento e vetor de joystick de rotação de caracteres):
[Component] public class Input { public Vector2 MoveVector; public Vector2 RotateVector; }
Componente com características estáticas do personagem (ele será armazenado no RuleBook, pois é uma característica básica e não muda durante a sessão do jogo):
[Component] public class PlayerStats { public float MoveSpeed; }
Ao decompor um recurso em sistemas, geralmente somos guiados pelo princípio da responsabilidade única: cada sistema deve cumprir uma e apenas uma função.
Os recursos podem consistir em vários sistemas. Vamos começar definindo o sistema de criação de personagem. O sistema passa por todas as solicitações para criar um personagem em um estado de jogo e, se o horário mundial atual corresponder ao necessário, ele cria uma nova entidade e anexa a ele os componentes que definem o jogador:
Jogador ,
Transformação ,
Movimento .
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) {
Agora considere o movimento do jogador no joystick. Precisamos de um sistema que lide com entrada. Ele passa por todos os componentes da entrada, calcula a velocidade do jogador (ele se levanta ou se move) e converte o vetor do joystick de rotação em um ângulo de rotação:
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); } } }
O próximo é o sistema de movimentação:
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; } } }
O sistema responsável pela rotação do objeto:
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; } } }
O MovementSystem e o
RotationSystem funcionam apenas com os componentes
Transform e
Movement . Eles são independentes da essência do jogador. Se outras entidades com componentes de
Movimento e
Transformação aparecerem em nosso jogo, a lógica do movimento também funcionará com elas.
Por exemplo, adicione um kit de primeiros socorros que se moverá em linha reta ao longo da semente e, ao selecionar, reabastecerá a saúde do personagem. Declare os componentes:
[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; }
Modificamos o componente das estatísticas do personagem adicionando o número máximo de vidas lá:
[Component] public class PlayerStats { public float MoveSpeed; public uint MaxHealth; }
Agora, modificamos o sistema de criação de personagens para que o personagem apareça com o máximo de vida útil:
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) {
Em seguida, declaramos o sistema de reprodução de nossos kits de primeiros socorros:
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) {
E um sistema para alterar a velocidade do kit de primeiros socorros. Para simplificar, o kit de primeiros socorros mudará de direção a cada poucos segundos:
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; } } } }
Como já anunciamos um
MovementSystem para mover objetos no jogo, precisamos apenas do
HealthPowerUpMovementSystem para alterar o vetor de velocidade, a cada N segundos.
Agora terminamos a seleção do kit de primeiros socorros e o acúmulo de HP no personagem. Precisamos de outro componente auxiliar para armazenar o número de vidas que o personagem receberá após selecionar o kit de primeiros socorros.
[Component] public class HealthToAdd { public int Health; public Entity Target; }
E um componente para remover nosso poder:
[Component] public class DeleteHealthPowerUpRequest { }
Escrevemos um sistema que processa a seleção do kit de primeiros socorros:
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; } } } } }
O sistema passa por todas as energias ativas e calcula a distância do jogador. Se algum jogador estiver dentro do raio de seleção, o sistema criará dois componentes de solicitação:
HealthToAdd - "solicitação" para adicionar vidas a um personagem;
DeleteHealthPowerUpRequest - "request" para remover o kit de primeiros socorros.
Por que não adicionar o número certo de vidas no mesmo sistema? Passamos do fato de o jogador receber HP não apenas de kits de primeiros socorros, mas também de outras fontes. Nesse caso, é mais conveniente separar os sistemas de seleção de kits de primeiros socorros e o sistema de acumulação de vida do personagem. Além disso, isso é mais consistente com o princípio de responsabilidade única.
Implementamos o sistema de acumular vidas para o personagem:
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); } } }
O sistema passa por todos os componentes do
HealthToAdd , calcula o número de vidas necessárias no componente
Health da entidade de
destino . Essa entidade não sabe nada sobre a origem e o destino e é bastante universal. Este sistema pode ser usado não apenas para calcular a vida do personagem, mas para quaisquer objetos que envolvam a presença de vidas e sua regeneração.
Para implementar o recurso com kits de primeiros socorros, resta adicionar o último sistema: o sistema de remoção do kit de primeiros socorros após sua seleção.
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); } } }
O
HealthPowerUpPickUpSystem cria uma solicitação para remover o kit de primeiros socorros. O sistema
DeleteHealthPowerUpSystem passa por todas essas solicitações e remove todos os componentes pertencentes à essência do kit de primeiros socorros.
Feito. Todos os sistemas de nossos exemplos são implementados. Há um ponto em trabalhar com o ECS - todos os sistemas são executados sequencialmente e essa ordem é importante.
No nosso exemplo, a ordem dos sistemas é a seguinte:
_systems = new List<ExecutableSystem> { new SpawnPlayerSystem(), new SpawnHealthPowerUpSystem(), new MovementControlSystem(), new HealthPowerUpMovementSystem(), new MovementSystem(), new RotationSystem(), new HealthPowerUpPickUpSystem(), new HealingSystem(), new DeleteHealthPowerUpSystem() };
No caso geral, os sistemas responsáveis pela criação de novas entidades e componentes vêm em primeiro lugar. Em seguida, os sistemas de tratamento e, finalmente, os sistemas de remoção e limpeza.
Com decomposição adequada, o ECS possui grande flexibilidade. Sim, nossa implementação não é perfeita, mas permite implementar recursos em pouco tempo, além de ter bom desempenho em dispositivos móveis modernos. Você pode ler mais sobre ECS aqui: