Fonctionnalités du jeu utilisant ECS: ajoutez une trousse de premiers soins au tireur



Passons des tapis aux choses sérieuses. Nous avons déjà parlé d'ECS, des cadres existants pour Unity et pourquoi ils ont écrit les leurs (vous pouvez trouver la liste à la fin de l'article). Examinons maintenant des exemples spécifiques de la façon dont nous utilisons ECS dans notre nouveau jeu de tir PvP mobile et de la façon dont nous mettons en œuvre les fonctionnalités du jeu. Je note que nous utilisons cette architecture uniquement pour simuler le monde sur le serveur et le système de prédiction sur le client. La visualisation et le rendu des objets sont implémentés à l'aide du modèle MVP - mais pas à ce sujet aujourd'hui.

L'architecture ECS est orientée données, toutes les données du monde du jeu sont stockées dans le soi-disant GameState et sont une liste d'entités (entités) avec quelques composants sur chacune d'entre elles. Un ensemble de composants détermine le comportement d'un objet. Et la logique du comportement des composants est concentrée dans les systèmes.

Le gamestate dans notre ECS se compose de deux parties: RuleBook et WorldState. RuleBook est un ensemble de composants qui ne changent pas pendant un match. Toutes les données statiques y sont stockées (caractéristiques des armes / personnages, composition des équipes) et envoyées au client une seule fois - lors de l'autorisation sur le serveur de jeu.

Prenons un exemple simple: faire apparaître un personnage et le déplacer dans un espace 2D à l'aide de deux joysticks. Tout d'abord, déclarez les composants.

Cela définit le joueur et est nécessaire pour la visualisation des personnages:

[Component] public class Player { } 

Le composant suivant est une demande de création d'un nouveau personnage. Il contient deux champs: le temps d'apparition du personnage (en ticks) et son ID:

 [Component] public class PlayerSpawnRequest { public int SpawnTime; public unit PlayerId; } 

La composante de l'orientation de l'objet dans l'espace:

 [Component] public class Transform { public Vector2 Position; public float Rotation; } 

Le composant qui stocke la vitesse actuelle de l'objet:

 [Component] public class Movement { public Vector2 Velocity; public float RotateToAngle; } 

Le composant qui stocke l'entrée du joueur (vecteur de joystick de mouvement et vecteur de joystick de rotation de personnage):

 [Component] public class Input { public Vector2 MoveVector; public Vector2 RotateVector; } 

Composant avec des caractéristiques statiques du personnage (il sera stocké dans RuleBook, car il s'agit d'une caractéristique de base et ne change pas pendant la session de jeu):

 [Component] public class PlayerStats { public float MoveSpeed; } 

Lors de la décomposition d'une caractéristique en systèmes, nous sommes souvent guidés par le principe de la responsabilité unique: chaque système doit remplir une et une seule fonction.

Les fonctionnalités peuvent comprendre plusieurs systèmes. Commençons par définir le système d'apparition des personnages. Le système traite toutes les demandes de création d'un personnage dans un état de jeu et si l'heure mondiale actuelle correspond à celle requise, il crée une nouvelle entité et y attache les composants qui définissent le joueur: joueur , transformation , mouvement .

 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) { // 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); // delete player spawn request deleter.Delete(avatarRequest.Key); } } } } 

Considérez maintenant le mouvement du joueur sur le joystick. Nous avons besoin d'un système qui gérera les entrées. Il passe par toutes les composantes de l'entrée, calcule la vitesse du joueur (qu'il se lève ou se déplace) et convertit le vecteur du joystick de rotation en un angle de rotation:

 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); } } } 

Le suivant est le système de mouvement:

 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; } } } 

Le système responsable de la rotation de l'objet:

 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 et RotationSystem fonctionnent uniquement avec les composants Transform et Movement . Ils sont indépendants de l'essence du joueur. Si d'autres entités avec des composants Mouvement et Transformation apparaissent dans notre jeu, la logique de mouvement fonctionnera également avec elles.

Par exemple, ajoutez une trousse de premiers soins qui se déplacera en ligne droite le long du point d'apparition et, lors de la sélection, reconstituera la santé du personnage. Déclarez les composants:

 [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; } 

Nous modifions le composant des statistiques du personnage en y ajoutant le nombre maximum de vies:

 [Component] public class PlayerStats { public float MoveSpeed; public uint MaxHealth; } 

Maintenant, nous modifions le système d'apparition des personnages pour que le personnage apparaisse avec un maximum de santé:

 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); } } } } 

Ensuite, nous déclarons le système d'apparition de nos trousses de premiers soins:

 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); } } } 

Et un système pour changer la vitesse de la trousse de premiers soins. Pour simplifier, la trousse de premiers soins changera de direction toutes les quelques secondes:

 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; } } } } 

Puisque nous avons déjà annoncé un MovementSystem pour déplacer les objets dans le jeu, nous n'avons besoin que du HealthPowerUpMovementSystem pour changer le vecteur de vitesse, toutes les N secondes.

Maintenant, nous terminons la sélection de la trousse de premiers soins et l'accumulation de HP au personnage. Nous aurons besoin d'un autre composant auxiliaire pour stocker le nombre de vies que le personnage recevra après avoir sélectionné la trousse de premiers soins.

 [Component] public class HealthToAdd { public int Health; public Entity Target; } 

Et un composant pour éliminer notre puissance:

 [Component] public class DeleteHealthPowerUpRequest { } 

Nous écrivons un système qui traite la sélection de la trousse de premiers soins:

 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; } } } } } 

Le système passe par tous les power-ups actifs et calcule la distance jusqu'au joueur. Si un joueur se trouve dans le rayon de sélection, le système crée deux composants de demande:

HealthToAdd - «demande» pour ajouter des vies à un personnage;
DeleteHealthPowerUpRequest - "demande" pour retirer la trousse de premiers soins.

Pourquoi ne pas ajouter le bon nombre de vies dans le même système? Nous partons du fait que le joueur reçoit des HP non seulement des trousses de premiers soins, mais aussi d'autres sources. Dans ce cas, il est plus opportun de séparer les systèmes de sélection de trousse de premiers soins et le système d'accumulation de vie du personnage. De plus, cela est plus conforme au principe de responsabilité unique.

Nous mettons en œuvre le système d'accumulation de vies pour le personnage:

 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); } } } 

Le système passe par tous les composants de HealthToAdd , calcule le nombre de vies requis dans le composant Health de l'entité cible cible . Cette entité ne sait rien de la source et de la cible et est assez universelle. Ce système peut être utilisé non seulement pour calculer la vie du personnage, mais pour tous les objets qui impliquent la présence de vies et leur régénération.

Pour implémenter la fonctionnalité avec des trousses de premiers secours, il reste à ajouter le dernier système: le système de retrait de trousse de premiers soins après sa sélection.

 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 crée une demande de suppression de la trousse de premiers soins. Le système DeleteHealthPowerUpSystem passe par toutes ces demandes et supprime tous les composants appartenant à l'essence de la trousse de premiers soins.

C'est fait. Tous les systèmes de nos exemples sont implémentés. Il y a un point de travailler avec ECS - tous les systèmes sont exécutés séquentiellement et cet ordre est important.

Dans notre exemple, l'ordre des systèmes est le suivant:

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

Dans le cas général, les systèmes chargés de créer de nouvelles entités et composants sont prioritaires. Puis les systèmes de traitement, et enfin les systèmes d'élimination et de nettoyage.

Avec une décomposition appropriée, ECS a une grande flexibilité. Oui, notre implémentation n'est pas parfaite, mais elle vous permet d'implémenter des fonctionnalités en peu de temps et offre également de bonnes performances sur les appareils mobiles modernes. Vous pouvez en savoir plus sur ECS ici:

Source: https://habr.com/ru/post/fr431660/


All Articles