Game features with ECS: we add kits to the shooter



    From carpets to serious things. We have already told about ECS, what frameworks for Unity are and why we wrote our own (the list can be found at the end of the article). And now we’ll focus on specific examples of how we use ECS in our new mobile PvP shooter and how we implement game features. I note that we apply this architecture only to simulate the world on the server and the prediction system on the client. Visualization and rendering of objects are implemented using the MVP pattern - but today is not about that.

    The ECS architecture is Data-oriented, all data from the game world is stored in the so-called GameState and is a list of entities with some components (components) on each of them. A set of components determines the behavior of an object. And the logic of the behavior of components is concentrated in the systems.

    Gamestate in our ECS consists of two parts: RuleBook and WorldState. RuleBook is a set of components that do not change during a match. All static data is stored there (characteristics of weapons / characters, teams) and sent to the client only once - when logging in to the game server.

    Consider a simple example: spawn a character and move it in 2D space using two joysticks. First, let's declare the components.

    This identifies the player and is required to render the character:

    [Component]
    publicclassPlayer
    {
    }
    

    The next component is a request to create a new character. It contains two fields: the character's spawn time (in ticks) and its ID:

    [Component]
    publicclassPlayerSpawnRequest
    {
     publicint SpawnTime;
     public unit PlayerId;
    }

    Component orientation of the object in space:

    [Component]
    publicclassTransform
    {
        public Vector2 Position;
        publicfloat Rotation;
    }

    Component storing the current speed of the object:

    [Component]
    publicclassMovement
    {
        public Vector2 Velocity;
        publicfloat RotateToAngle;
    }

    Component that stores player input (motion joystick vector and character rotation joystick vector):

    [Component]
    publicclassInput
    {
        public Vector2 MoveVector;
        public Vector2 RotateVector;
    }

    Component with static characteristics of the character (it will be stored in RuleBook, since this is a basic characteristic and does not change during the game session):

    [Component]
    publicclassPlayerStats
    {
        publicfloat MoveSpeed;
    }

    When decomposing features into systems, we are often guided by the single responsibility principle: each system must perform one and only one function.

    Features can consist of several systems. Let's start by defining the character spawn system. The system passes through all requests for creating a character in the gamestate, and if the current world time matches the required one, it creates a new entity and attaches to it the components that define the player: Player , Transform , Movement .

    publicclassSpawnPlayerSystem : ExecutableSystem
       {
           publicoverridevoidExecute(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 IDvar 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);
                   }
               }
           }
       }
    

    Now consider the movement of the player on the joystick. We need a system that will handle the input. It passes through all the components of the input, calculates the player’s speed (standing or moving) and converts the rotation vector of the joystick to the rotation angle:

    MovementControlSystem
    publicclassMovementControlSystem : ExecutableSystem
        {
            publicoverridevoidExecute(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);
                }
            }
        }

    Next is the movement system:

    publicclassMovementSystem : ExecutableSystem
        {
            publicoverridevoidExecute(GameState gs)
            {
                foreach (var pair in gs.WorldState.Movement)
                {
                    var transform = gs.WorldState.Transform[pair.Key];
                    transform.Position += pair.Value.Velocity * GameState.TickDurationSec;
                }
            }
        }

    The system responsible for the rotation of the object:

    publicclassRotationSystem : ExecutableSystem
        {
            publicoverridevoidExecute(GameState gs)
            {
                foreach (var pair in gs.WorldState.Movement)
                {
                    var transform = gs.WorldState.Transform[pair.Key];
                    transform.Angle = pair.Value.RotateToAngle;
                }
            }
        }

    The MovementSystem and RotationSystem systems work only with Transform and Movement components . They are independent of the essence of the player. If other entities with the Movement and Transform components appear in our game , the movement logic will also work with them.

    For example, let's add a first-aid kit that will move in a straight line along the spawn and, when selected, will replenish the health of the character. Declare the components:

    [Component]
    publicclassHealth
    {
        publicuint CurrentHealth;
        publicuint MaxHealth;
    }
    [Component]
    publicclassHealthPowerUp
    {
         publicuint NextChangeDirection;
    }
    [Component]
    publicclassHealthPowerUpSpawnRequest
    {
        publicuint SpawnRequest;
    }
    [Component]
    publicclassHealthPowerUpStats
    {
         publicfloat HealthRestorePercent;
         publicfloat MoveSpeed;
         publicfloat SecondsToChangeDirection;
         publicfloat PickupRadius;
         publicfloat TimeToSpawn;
    }

    Modify the stat component of the character by adding the maximum number of lives there:

    [Component]
    publicclassPlayerStats
    {
        publicfloat MoveSpeed;
        publicuint MaxHealth;
    }

    Now we modify the character's spawn system so that the character appears with maximum health:

    publicclassSpawnPlayerSystem : ExecutableSystem
       {
           publicoverridevoidExecute(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 IDvar 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);
                   }
               }
           }
       }

    Then we announce the spawn system of our first-aid kits:

    publicclassSpawnHealthPowerUpSystem : ExecutableSystem
       {
           publicoverridevoidExecute(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);
               }
           }
       }

    And the system changes the speed of the first-aid kit. For simplicity, the first-aid kit will change direction every few seconds:

    publicclassHealthPowerUpMovementSystem : ExecutableSystem
    {
        publicoverridevoidExecute(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;
                  }
              }
        }
    }

    Since we have already announced the MovementSystem to move objects in the game, we will only need the HealthPowerUpMovementSystem system to change the motion speed vector every N seconds.

    Now we finish the selection of the first-aid kit and the accrual of the HP character. We will need another auxiliary component to store the number of lives that a character will receive after selecting a first aid kit.

    [Component]
    publicclassHealthToAdd
    {
    publicint Health;
    public Entity Target;
    }
    

    And the component to remove our call:

    [Component]
    publicclassDeleteHealthPowerUpRequest
    {
    }

    We write the system processing the selection of first-aid kits:

    publicclassHealthPowerUpPickUpSystem : ExecutableSystem
    {
      publicoverridevoidExecute(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;
               }
             }
          }
      }
    }

    The system passes through all active rapers and calculates the distance to the player. If any player is within the selection radius, the system creates two query components:

    HealthToAdd - a “request” to add a character to the lives;
    DeleteHealthPowerUpRequest - "request" to delete the first-aid kit.

    Why not add the required number of lives in the same system? We assume that the player receives HP not only from first-aid kits, but also from other sources. In this case, it is more expedient to separate the systems for selecting the first-aid kit and the system for charging the lives of the character. In addition, it is more consistent with the Single Responsibility Principle.

    Implement a character's life accrual system:

    publicclassHealingSystem : ExecutableSystem
    {
      publicoverridevoidExecute(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);
          }
      }
    }

    The system traverses all components of HealthToAdd , charges the required number of lives in the Health component of the target entity Target . This entity knows nothing about the source and target object and is rather universal. This system can be used not only for the accrual of character’s lives, but for any objects that assume the presence of lives and their regeneration.

    To implement the features with first-aid kits, it remains to add the latest system: a system for removing the first-aid kit after its selection.

    publicclassDeleteHealthPowerUpSystem : ExecutableSystem
    {
      publicoverridevoidExecute(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);
          }
      }
    }

    The HealthPowerUpPickUpSystem system creates a request to remove the first-aid kit. The DeleteHealthPowerUpSystem system follows all such requests and deletes all components that belong to the essence of the first-aid kit.

    Is done. All systems from our examples are implemented. There is one thing about working with ECS - all systems are executed sequentially and this order is important.

    In our example, the order of the systems is as follows:

    _systems = new List<ExecutableSystem>
    {
    new SpawnPlayerSystem(),
    new SpawnHealthPowerUpSystem(),
    new MovementControlSystem(),
    new HealthPowerUpMovementSystem(),
    new MovementSystem(),
    new RotationSystem(),
    new HealthPowerUpPickUpSystem(),
    new HealingSystem(),
    new DeleteHealthPowerUpSystem()
    };

    In general, the first to go are the systems responsible for creating new entities and components. Then the processing systems and, at the end, the removal and cleaning systems.

    With proper decomposition, ECS has great flexibility. Yes, our implementation is not perfect, but it allows to implement features in a short time, and also has good performance on modern mobile devices. You can read more about ECS here:


    Also popular now: