Spielfunktionen mit ECS: Fügen Sie dem Schützen ein Erste-Hilfe-Set hinzu



Gehen wir von Teppichen zu ernsten Dingen über. Wir haben bereits über ECS gesprochen, welche Frameworks es für Unity gibt und warum sie ihre eigenen geschrieben haben (die Liste finden Sie am Ende des Artikels). Konzentrieren wir uns nun auf konkrete Beispiele, wie wir ECS in unserem neuen mobilen PvP-Shooter verwenden und wie wir Spielfunktionen implementieren. Ich stelle fest, dass wir diese Architektur nur verwenden, um die Welt auf dem Server und das Vorhersagesystem auf dem Client zu simulieren. Objektvisualisierung und -rendering werden nach dem MVP-Muster implementiert - aber heute nicht mehr.

Die ECS-Architektur ist datenorientiert, alle Daten der Spielwelt werden im sogenannten GameState gespeichert und sind eine Liste von Entitäten (Entitäten) mit einigen Komponenten auf jeder von ihnen. Eine Reihe von Komponenten bestimmt das Verhalten eines Objekts. Und die Logik des Komponentenverhaltens konzentriert sich auf Systeme.

Der Gamestate in unserem ECS besteht aus zwei Teilen: RuleBook und WorldState. RuleBook ist eine Reihe von Komponenten, die sich während eines Spiels nicht ändern. Alle statischen Daten werden dort gespeichert (Eigenschaften von Waffen / Charakteren, Zusammensetzung der Teams) und nur einmal an den Client gesendet - während der Autorisierung auf dem Spielserver.

Stellen Sie sich ein einfaches Beispiel vor: Spawnen Sie einen Charakter und verschieben Sie ihn mit zwei Joysticks in einen 2D-Raum. Deklarieren Sie zunächst die Komponenten.

Dies definiert den Spieler und ist für die Charaktervisualisierung erforderlich:

[Component] public class Player { } 

Die nächste Komponente ist eine Anforderung zum Erstellen eines neuen Zeichens. Es enthält zwei Felder: Zeichen-Spawn-Zeit (in Ticks) und seine ID:

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

Die Komponente der Ausrichtung des Objekts im Raum:

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

Die Komponente, die die aktuelle Geschwindigkeit des Objekts speichert:

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

Die Komponente, die die Eingaben des Players speichert (Bewegungs-Joystick-Vektor und Joystick-Vektor für die Zeichendrehung):

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

Komponente mit statischen Eigenschaften des Charakters (sie wird im Regelbuch gespeichert, da dies eine grundlegende Eigenschaft ist und sich während der Spielsitzung nicht ändert):

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

Bei der Zerlegung eines Features in Systeme orientieren wir uns häufig am Prinzip der Einzelverantwortung: Jedes System muss nur eine Funktion erfüllen.

Features können aus mehreren Systemen bestehen. Beginnen wir mit der Definition des Charakter-Spawn-Systems. Das System durchläuft alle Anforderungen zum Erstellen eines Charakters in einem Spielstatus. Wenn die aktuelle Weltzeit mit der erforderlichen übereinstimmt, erstellt es eine neue Entität und fügt ihr die Komponenten hinzu, die den Spieler definieren: Spieler , Transformieren , Bewegung .

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

Betrachten Sie nun die Bewegung des Players auf dem Joystick. Wir brauchen ein System, das Eingaben verarbeitet. Es durchläuft alle Komponenten der Eingabe, berechnet die Geschwindigkeit des Spielers (er steht oder bewegt sich) und wandelt den Vektor des Joysticks in einen Drehwinkel um:

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

Das nächste ist das Bewegungssystem:

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

Das System, das für die Drehung des Objekts verantwortlich ist:

 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 und RotationSystem funktionieren nur mit Transformations- und Bewegungskomponenten . Sie sind unabhängig von der Essenz des Spielers. Wenn andere Objekte mit Bewegungs- und Transformationskomponenten in unserem Spiel erscheinen, funktioniert auch die Bewegungslogik mit ihnen.

Fügen Sie beispielsweise ein Erste-Hilfe-Set hinzu, das sich in einer geraden Linie entlang des Spawns bewegt und bei Auswahl die Gesundheit des Charakters wieder auffüllt. Deklarieren Sie die Komponenten:

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

Wir ändern die Komponente der Statistiken des Charakters, indem wir die maximale Anzahl von Leben dort hinzufügen:

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

Jetzt modifizieren wir das Charakter-Spawn-System so, dass der Charakter mit maximaler Gesundheit erscheint:

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

Dann erklären wir das Spawn-System unserer Erste-Hilfe-Sets:

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

Und ein System zum Ändern der Geschwindigkeit des Erste-Hilfe-Kits. Zur Vereinfachung ändert das Erste-Hilfe-Set alle paar Sekunden die Richtung:

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

Da wir bereits ein MovementSystem angekündigt haben, um Objekte im Spiel zu bewegen, benötigen wir nur das HealthPowerUpMovementSystem , um den Geschwindigkeitsvektor alle N Sekunden zu ändern.

Jetzt beenden wir die Auswahl des Erste-Hilfe-Kits und die Anhäufung von HP für den Charakter. Wir benötigen eine weitere Hilfskomponente, um die Anzahl der Leben zu speichern, die der Charakter nach Auswahl des Erste-Hilfe-Sets erhält.

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

Und eine Komponente, um unsere Kraft zu entfernen:

 [Component] public class DeleteHealthPowerUpRequest { } 

Wir schreiben ein System, das die Auswahl des Erste-Hilfe-Kits verarbeitet:

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

Das System durchläuft alle aktiven Power-Ups und berechnet die Entfernung zum Spieler. Befindet sich ein Spieler innerhalb des Auswahlradius, erstellt das System zwei Anforderungskomponenten:

HealthToAdd - "Anfrage" zum Hinzufügen von Leben zu einem Charakter;
DeleteHealthPowerUpRequest - "Anfrage" zum Entfernen des Erste-Hilfe-Kits.

Warum nicht die richtige Anzahl von Leben im selben System hinzufügen? Wir gehen davon aus, dass der Spieler HP nicht nur aus Erste-Hilfe-Sets, sondern auch aus anderen Quellen erhält. In diesem Fall ist es zweckmäßiger, die Auswahlsysteme für Erste-Hilfe-Sets und das Lebensabgrenzungssystem des Charakters zu trennen. Darüber hinaus steht dies im Einklang mit dem Prinzip der Einzelverantwortung.

Wir implementieren das System, dem Charakter Leben zu verschaffen:

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

Das System durchläuft alle Komponenten von HealthToAdd und berechnet die erforderliche Anzahl von Leben in der Health- Komponente der Zielzielentität . Diese Entität weiß nichts über die Quelle und das Ziel und ist ziemlich universell. Dieses System kann nicht nur zur Berechnung des Lebens des Charakters verwendet werden, sondern auch für alle Objekte, bei denen Leben vorhanden sind und deren Regeneration.

Um die Funktion mit Erste-Hilfe-Kits zu implementieren, muss noch das letzte System hinzugefügt werden: das Erste-Hilfe-Kit-Entfernungssystem nach seiner Auswahl.

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

Das HealthPowerUpPickUpSystem erstellt eine Anforderung zum Entfernen des Erste-Hilfe-Kits. Das DeleteHealthPowerUpSystem- System durchläuft alle derartigen Anforderungen und entfernt alle Komponenten, die zum Kern des Erste-Hilfe-Kits gehören.

Fertig. Alle Systeme aus unseren Beispielen sind implementiert. Es gibt einen Punkt bei der Arbeit mit ECS: Alle Systeme werden nacheinander ausgeführt, und diese Reihenfolge ist wichtig.

In unserem Beispiel ist die Reihenfolge der Systeme wie folgt:

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

Im allgemeinen Fall stehen die Systeme an erster Stelle, die für die Erstellung neuer Entitäten und Komponenten verantwortlich sind. Dann die Behandlungssysteme und schließlich die Entfernungs- und Reinigungssysteme.

Bei richtiger Zersetzung weist ECS eine große Flexibilität auf. Ja, unsere Implementierung ist nicht perfekt, aber Sie können Funktionen in kurzer Zeit implementieren und haben auch eine gute Leistung auf modernen Mobilgeräten. Weitere Informationen zu ECS finden Sie hier:

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


All Articles