Pasemos de las
alfombras a las cosas serias. Ya hablamos sobre ECS, qué marcos existen para Unity y por qué escribieron los suyos (puede encontrar la lista al final del artículo). Ahora analicemos ejemplos específicos de cómo usamos ECS en nuestro nuevo tirador PvP móvil y cómo implementamos las funciones del juego. Observo que usamos esta arquitectura solo para simular el mundo en el servidor y el sistema de predicción en el cliente. La visualización y la representación de objetos se implementan utilizando el patrón MVP, pero no se trata de eso hoy.
La arquitectura ECS está orientada a datos, todos los datos del mundo del juego se almacenan en el llamado GameState y es una lista de entidades (entidades) con algunos componentes en cada uno de ellos. Un conjunto de componentes determina el comportamiento de un objeto. Y la lógica del comportamiento de los componentes se concentra en los sistemas.
El estado del juego en nuestro ECS consta de dos partes: RuleBook y WorldState. RuleBook es un conjunto de componentes que no cambian durante un partido. Todos los datos estáticos se almacenan allí (características de armas / personajes, composición de equipos) y se envían al cliente solo una vez, durante la autorización en el servidor del juego.
Considere un ejemplo simple: genere un personaje y muévalo en un espacio 2D usando dos joysticks. Primero, declara los componentes.
Esto define al jugador y es necesario para la visualización del personaje:
[Component] public class Player { }
El siguiente componente es una solicitud para crear un nuevo personaje. Contiene dos campos: tiempo de generación de caracteres (en ticks) y su ID:
[Component] public class PlayerSpawnRequest { public int SpawnTime; public unit PlayerId; }
El componente de la orientación del objeto en el espacio:
[Component] public class Transform { public Vector2 Position; public float Rotation; }
El componente que almacena la velocidad actual del objeto:
[Component] public class Movement { public Vector2 Velocity; public float RotateToAngle; }
El componente que almacena la entrada del jugador (vector de joystick de movimiento y vector de joystick de rotación de caracteres):
[Component] public class Input { public Vector2 MoveVector; public Vector2 RotateVector; }
Componente con características estáticas del personaje (se almacenará en RuleBook, ya que esta es una característica básica y no cambia durante la sesión del juego):
[Component] public class PlayerStats { public float MoveSpeed; }
Al descomponer una característica en sistemas, a menudo nos guiamos por el principio de responsabilidad única: cada sistema debe cumplir una y solo una función.
Las características pueden consistir en varios sistemas. Comencemos definiendo el sistema de generación de personajes. El sistema pasa por todas las solicitudes para crear un personaje en un estado de juego y si la hora mundial actual coincide con la requerida, crea una nueva entidad y le asigna los componentes que definen al jugador:
Jugador ,
Transformación ,
Movimiento .
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) {
Ahora considere el movimiento del jugador en el joystick. Necesitamos un sistema que maneje la entrada. Pasa a través de todos los componentes de la entrada, calcula la velocidad del jugador (se para o se mueve) y convierte el vector del joystick de rotación en un ángulo de rotación:
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); } } }
El siguiente es el sistema de movimiento:
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; } } }
El sistema responsable de la rotación del 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; } } }
MovementSystem y
RotationSystem funcionan solo con los componentes
Transformar y
Movimiento . Son independientes de la esencia del jugador. Si otras entidades con componentes de
Movimiento y
Transformación aparecen en nuestro juego, la lógica de movimiento también funcionará con ellas.
Por ejemplo, agrega un botiquín de primeros auxilios que se moverá en línea recta a lo largo del engendro y, cuando lo selecciones, repondrá la salud del personaje. Declara los 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 el componente de las estadísticas del personaje agregando el número máximo de vidas allí:
[Component] public class PlayerStats { public float MoveSpeed; public uint MaxHealth; }
Ahora modificamos el sistema de generación de personajes para que aparezca con la máxima salud:
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) {
Luego declaramos el sistema de generación de nuestros botiquines de primeros auxilios:
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) {
Y un sistema para cambiar la velocidad del botiquín de primeros auxilios. Para simplificar, el botiquín de primeros auxilios cambiará de dirección cada pocos 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 ya anunciamos un
MotionSystem para mover objetos en el juego, solo necesitamos el
HealthPowerUpMovementSystem para cambiar el vector de velocidad, cada N segundos.
Ahora terminamos la selección del botiquín de primeros auxilios y la acumulación de HP para el personaje. Necesitaremos otro componente auxiliar para almacenar la cantidad de vidas que recibirá el personaje después de seleccionar el botiquín de primeros auxilios.
[Component] public class HealthToAdd { public int Health; public Entity Target; }
Y un componente para eliminar nuestro poder:
[Component] public class DeleteHealthPowerUpRequest { }
Escribimos un sistema que procesa la selección del botiquín de primeros auxilios:
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; } } } } }
El sistema pasa por todos los potenciadores activos y calcula la distancia al jugador. Si algún jugador está dentro del radio de selección, el sistema crea dos componentes de solicitud:
HealthToAdd - "solicitud" para agregar vidas a un personaje;
DeleteHealthPowerUpRequest - "solicitud" para eliminar el botiquín de primeros auxilios.
¿Por qué no agregar el número correcto de vidas en el mismo sistema? Procedemos del hecho de que el jugador recibe HP no solo de los botiquines de primeros auxilios, sino también de otras fuentes. En este caso, es más conveniente separar los sistemas de selección de botiquín de primeros auxilios y el sistema de acumulación de vida del personaje. Además, esto es más consistente con el Principio de Responsabilidad Única.
Implementamos el sistema de acumular vidas para el personaje:
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); } } }
El sistema pasa por todos los componentes de
HealthToAdd , calcula el número requerido de vidas en el componente de
Salud de la entidad de
destino . Esta entidad no sabe nada sobre el origen y el destino y es bastante universal. Este sistema puede usarse no solo para calcular la vida del personaje, sino también para cualquier objeto que implique la presencia de vidas y su regeneración.
Para implementar la función con kits de primeros auxilios, queda por agregar el último sistema: el sistema de extracción de kits de primeros auxilios después de su selección.
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 crea una solicitud para eliminar el botiquín de primeros auxilios. El sistema
DeleteHealthPowerUpSystem pasa por todas esas solicitudes y elimina todos los componentes que pertenecen a la esencia del botiquín de primeros auxilios.
Listo Todos los sistemas de nuestros ejemplos están implementados. Hay un punto de trabajar con ECS: todos los sistemas se ejecutan secuencialmente y este orden es importante.
En nuestro ejemplo, el orden de los sistemas es el siguiente:
_systems = new List<ExecutableSystem> { new SpawnPlayerSystem(), new SpawnHealthPowerUpSystem(), new MovementControlSystem(), new HealthPowerUpMovementSystem(), new MovementSystem(), new RotationSystem(), new HealthPowerUpPickUpSystem(), new HealingSystem(), new DeleteHealthPowerUpSystem() };
En el caso general, los sistemas que son responsables de crear nuevas entidades y componentes son lo primero. Luego los sistemas de tratamiento, y finalmente los sistemas de remoción y limpieza.
Con la descomposición adecuada, ECS tiene una gran flexibilidad. Sí, nuestra implementación no es perfecta, pero le permite implementar funciones en poco tiempo, y también tiene un buen rendimiento en dispositivos móviles modernos. Puede leer más sobre ECS aquí: