Entwickeln von Spielen unter LibGDX mithilfe der Entity Component System-Vorlage

Hallo Habr! Mein Name ist Andrey Shilo, ich bin ein Android-Entwickler bei FINCH . Heute werde ich Ihnen erzählen, welche Fehler beim Schreiben selbst des einfachsten Spiels nicht gemacht werden sollten und warum der Architekturansatz von Entity Component System (ECS) cool ist.

Das erste Mal ist immer schmerzhaft


Wir haben ein lustiges Projekt für eine große Medienholding, die ein nicht standardisiertes soziales Netzwerk ist. Netzwerk mit Beiträgen, Kommentaren, Likes und Videos. Einmal gaben sie uns eine Aufgabe - Spielmechanik als Promotion einzuführen. Das Spiel sah aus wie ein einfaches Mini-Rennen, bei dem das Auto mit einem Fingertipp auf die linke / rechte Spur gefahren wurde. Um Hindernissen auszuweichen und Booster zu sammeln, musste man die Ziellinie erreichen. Insgesamt hatte der Spieler drei Leben.

Das Spiel sollte natürlich direkt in der Anwendung auf einem separaten Bildschirm implementiert werden. Wir haben uns bedingungslos für LibGDX als Engine entschieden, da Sie das Spiel auf kotiln codieren und auf dem Desktop debuggen und das Spiel als Java-Anwendung starten können . Zur gleichen Zeit hatten wir keine Leute, die andere Engines kannten, die in der Anwendung implementiert werden konnten (wenn Sie es wissen, dann teilen Sie es).

Das Spiel sieht so aus:



Da das Spiel nach dem ursprünglichen TK einfach zu sein schien, haben wir uns nicht mit architektonischen Ansätzen befasst. Darüber hinaus vergehen die Werbeaktionen selbst schnell - durchschnittlich dauert eine Aktie anderthalb Monate. Dementsprechend wird der Spielcode später einfach ausgeschnitten und erst bei der nächsten solchen Promo benötigt, vorausgesetzt, jemand möchte so etwas wiederholen.

Alle oben beschriebenen Faktoren und die geliebten, drängenden Manager haben uns dazu gedrängt, eine Spielmechanik ohne Architektur zu schreiben.

Beschreibung des entstehenden Spiels


Der Großteil des Codes wurde in den Klassen MyGdxGame: Game , GameScreen: Screen und TrafficGame: Actor kompiliert.

MyGdxGame - ist der Einstiegspunkt zu Beginn des Spiels, hier werden die Parameter in Form von Strings an den Konstruktor übergeben. Hier werden auch GameScreen und Spielparameter angelegt, die in einer anderen Form an diese Klasse weitergegeben werden.

GameScreen - Erstellt einen Darsteller des Spiels TrafficGame, fügt ihn der Szene hinzu, überträgt die bereits erwähnten Parameter und „lauscht“ auch den Klicks des Benutzers auf den Bildschirm und ruft die entsprechenden Methoden des Darstellers TrafficGame auf.

TrafficGame - der Hauptdarsteller der Szene, in der alle Bewegungen des Spiels stattfinden: Rendering und Logik der Arbeit.

Die Verwendung von scene2d ermöglicht zwar das Bauen von Akteur-Nesting-Bäumen, dies ist jedoch nicht die beste architektonische Lösung. Für die Implementierung eines UI / UX-Spiels (unter LibGDX) ist scene2d jedoch eine hervorragende Option.

In unserem Fall verfügt TrafficGame über eine riesige Sammlung gemischter Instanzen und allerlei Verhaltensflags, die in Methoden mit großen when- Konstrukten zulässig waren. Ein Beispiel:

var isGameActive: Boolean = true set(value) { backgroundActor?.isGameActive = value boostersMap.values.filterNotNull().forEach { it.isGameActive = value } obstaclesMap.values.filterNotNull().forEach { it.isGameActive = value } finishActor.isGameActive = value field = value } private var backgroundActoolbarActor private val pauseButtonActor: PauseButtonActor private val finishActor: FinishActor private var isQuizWon = falser: BackgroundActor? = null private var playerCarActor: PlayerCarActor private var toolbarActor: To private var pointsTime = 0f private var totalTimeElapsed = 0 private val timeToFinishTheGame = 50 private var lastQuizBoosterTime = 0.0f private var lastBoosterTime = 0.0f private val boostersMap = hashMapOf<Long?, BoosterActor?>() private var boosterSpawnedCount = 0 private var totalBoostersEatenCount = 0 private val boosterLimit = 20 private var lastBoosterYPos = 0.0f private var toGenerateBooster = false private var lastObstacleTime = 0.0f private var obstaclesMap = hashMapOf<Long?, ObstacleActor?>() 

Natürlich solltest du nicht so schreiben. Aber in der Verteidigung werde ich sagen, dass es passiert ist, weil:

  • Sie müssen jetzt beginnen, und wir werden die endgültige TK mit dem Design später zeigen. Klassisch
  • Bereits bekannte Architekturen (MVP / MVC / MVVM etc.) sind für die Implementierung des Spielprozesses nicht geeignet, da sie rein für die Benutzeroberfläche ausgelegt sind, im Spiel geschieht alles in Echtzeit.
  • Ursprünglich schien das Spiel einfach zu sein, aber es erforderte tatsächlich viel Code, wobei eine große Anzahl von Nuancen berücksichtigt wurde, von denen der Großteil während des Schreibens des Spiels aufgetaucht ist.



Zusätzlich zu all diesen Schwierigkeiten gibt es ein weiteres häufiges Problem bei der Vererbung. Wenn Sie ein Spiel, beispielsweise einen Plattformer, erschweren, stellt sich die Frage: "Wie verteile ich wiederverwendbaren Code zwischen den Objekten des Spiels?". Die am häufigsten gewählte Option ist die Vererbung, bei der der wiederverwendbare Code in den übergeordneten Klassen abgelegt wird. Diese Lösung verursacht jedoch viele Probleme, wenn Bedingungen angezeigt werden, die nicht in den Vererbungsbaum passen:



Und normalerweise lösen sie solche Probleme, indem sie die Struktur des Vererbungsbaums von Grund auf neu schreiben (diesmal ist es sicherlich besser), oder die Elternklassen sind mit Krücken zerbrochen.



ECS ist unser Alles


Eine ganz andere Geschichte ist unser zweites Promo-Spiel. Sie war wie Flappy Bird , aber mit Unterschieden: Der Charakter wurde von der Stimme gesteuert und die Decke und der Boden waren keine Hindernisse - man konnte darauf rutschen.
Ein Beispiel für das Gameplay und zum Vergleich den Vorgang des Spielens von Flappy Bird:




Zur Verdeutlichung wird in diesem Beispiel die Kamera entfernt, um die Backstage des Spiels zu sehen. Der Boden und die Decke sind quadratische Blöcke, die bis zum Rand an den Anfang verschoben werden, und Hindernisse werden nach einem vorgegebenen Muster erzeugt, das von hinten kommt. Das Design des Spiels wurde von den Kunden ausgewählt, also wundern Sie sich nicht.

Ich mag die Entwicklung von Spielen für mobile Geräte und in der Freizeit werde ich mich experimentell mit Spielmustern und allem anderen beschäftigen, was mit der Spieleentwicklung zu tun hat. Ich las ein Buch über Game-Design-Muster , verstand aber nicht, wie die wahre Architektur der Gameplay-Logik aussehen sollte, bis ich auf ECS stieß.

Entity Component System - das Designmuster, das in der Spieleentwicklung am häufigsten verwendet wird. Die Hauptidee des Musters ist Komposition statt Vererbung . Die Komposition ermöglicht es Ihnen, verschiedene Mechanismen an Spielobjekten zu mischen. Dies ermöglicht es Ihnen in Zukunft, die Einstellung von Objekteigenschaften an einen Spieledesigner zu delegieren, beispielsweise durch einen schriftlichen Konstruktor. Da ich mit diesem Muster bereits vertraut war, beschlossen wir, es im zweiten Spiel anzuwenden.

Betrachten Sie die Komponenten des Musters:

  • Komponente - Objekte mit einer einfachen Datenstruktur, die keine Logik enthalten oder als Verknüpfung dienen. Die Komponenten sind nach Verwendungszweck unterteilt und bestimmen alle Eigenschaften der Spielobjekte. Geschwindigkeit, Position, Textur, Körper usw. usw. All dies wird in den Komponenten beschrieben und dann zu den Spielobjekten hinzugefügt.

     class VelocityComponent: Component { val velocity = Vector2() } 
  • Entity - Spielobjekte: Hindernisse / Booster / kontrollierter Held und sogar Hintergrund. Sie haben keine speziellen Klassen nach Typ: UltraMegaSuperman: GameUnit, sondern sind einfach Container für das Komponentenset. Die Tatsache, dass eine bestimmte Entität dabei UltraMegaSuperman ist, definiert deren Komponentensatz und deren Parameter.
    In unserem Fall hatte die Hauptfigur beispielsweise die folgenden Komponenten:

    • TextureComponent - definiert, was auf dem Bildschirm gezeichnet werden soll
    • TransformComponent - Die Position des Objekts im Spielfeld
    • VelocityComponent - Geschwindigkeit des Objekts im Spielfeld
    • HeroControllerComponent - enthält Werte, die die Bewegung des Helden beeinflussen
    • ImmortalityTimeComponent - enthält die verbleibende Zeit der Unsterblichkeit
    • DynamicComponent - Gibt an, dass das Objekt nicht statisch ist und der Schwerkraft ausgesetzt ist
    • BodyComponent - Definiert den physischen 2D-Heldenkörper, der zur Berechnung von Kollisionen benötigt wird
  • System - enthält den Code für die Verarbeitung von Daten aus den Komponenten jeder Entität. Sie dürfen keine Entity- und / oder Component-Objekte speichern , da dies dem Muster widerspricht. Idealerweise sollten sie überhaupt sauber sein.

    Systeme erledigen die ganze Drecksarbeit: Zeichnen Sie alle Objekte im Spiel, bewegen Sie das Objekt anhand seiner Geschwindigkeit, prüfen Sie auf Kollisionen, ändern Sie die Geschwindigkeit von der eingehenden Steuerung aus und so weiter. Zum Beispiel sieht der Effekt der Schwerkraft so aus:

     override fun processEntity(entity: Entity, deltaTime: Float) { entity.getComponent(VelocityComponent::class.java) .velocity .add(0f, -GRAVITY * deltaTime) } 

    Die Spezialisierung jedes Systems bestimmt die Anforderungen an die Entitäten, die es verarbeiten muss. Das heißt, im obigen Beispiel muss die Entität über die Geschwindigkeitskomponenten VelocityComponent und DynamicComponent verfügen, damit diese Entität verarbeitet werden kann. Andernfalls ist diese Entität für das System und für die anderen nicht interessant. Um beispielsweise eine Textur zu zeichnen, müssen Sie wissen, um welche Textur es sich bei TextureComponent handelt und wo Sie die TransformComponent zeichnen müssen. Um die Anforderungen in jedem System zu bestimmen, wird die Familie in den Konstruktor geschrieben, in dem die Komponentenklassen angegeben sind.

     Family.all(TransformComponent::class.java, TextureComponent::class.java).get() 

    Auch die Reihenfolge der Verarbeitungseinheiten innerhalb des Systems kann durch einen Komparator angepasst werden. Darüber hinaus wird die Reihenfolge der Ausführung von Systemen in der Maschine auch durch den Prioritätswert geregelt.

Der Motor kombiniert drei Komponenten. Es enthält alle Systeme und alle Entitäten im Spiel. Zu Beginn des Spiels alle notwendigen Systeme im Spiel

 engine.apply { addSystem(ControlSystem()) addSystem(GravitySystem()) addSystem(renderingSystem) addSystem(MovementSystem()) addSystem(EnemyGeneratorSystem()) } 
sowie Starteinheiten werden der Engine hinzugefügt,
 val hero: Entity = engine.createEntity() engine.addEntity(hero) 

PooledEngine :: createEntity - Ruft ein Entity-Objekt aus dem Pool ab , da Entities während des Spiels erstellt werden können, um den Speicher nicht zu verunreinigen. Bei Bedarf werden sie aus dem Pool der Objekte übernommen und beim Löschen zurückgesetzt. Ähnliches gilt für Komponenten. Beim Empfang von Komponenten aus dem Pool müssen alle Felder initialisiert werden, da sie möglicherweise Informationen zur vorherigen Verwendung dieser Komponente enthalten.

Die Beziehung zwischen den Hauptteilen des Musters wird nachfolgend dargestellt:



Die Engine enthält eine Sammlung von Systemen und eine Sammlung von Entitäten. Jedes System erhält eine Verknüpfung von der Engine zu einer Sammlung von Entitäten, die eine Auswahl aus der allgemeinen Sammlung gemäß den Anforderungen des Systems darstellt und während des Spiels aktualisiert wird, wenn sich Entitäten und Komponenten ändern. Jede Entität enthält eine Sammlung ihrer Komponenten, die sie im Spiel definieren.

Der Spielzyklus ist wie folgt aufgebaut:

  1. Mit der Implementierung des "Game Cycle" -Musters von LibGDX erhalten wir bei der Aktualisierungsmethode das Inkrement zu jedem Zeitschritt - deltaTime.
  2. Als nächstes geben wir die Zeit an den Motor weiter. Und er iteriert seinerseits zyklisch durch das System und verteilt sie deltaTime.
     for (i in 0 until systems.size()) { val system = systems[i] if (system.checkProcessing()) { system.update(deltaTime) } } 
  3. Systeme, die DeltaTime empfangen, sortieren ihre Entitäten und wenden Änderungen unter Berücksichtigung von DeltaTime auf sie an.
     for (i in 0 until entities.size()) { processEntity(entities[i], deltaTime) } 

Dies geschieht in jeder Hinsicht.

ECS Vorteile


  1. Daten stehen an erster Stelle . Da Systeme nur die Entitäten verarbeiten, die zu ihnen passen, wird das System in Abwesenheit solcher Entitäten einfach nichts tun. Dies ermöglicht es, neue Funktionen zu testen und zu debuggen und nur die dafür erforderlichen Entitäten zu erstellen.

    Sie haben beispielsweise das Spiel "Tanks" erstellt. Nach einiger Zeit haben Sie beschlossen, einen neuen Geländetyp hinzuzufügen - "Lava". Wenn der Panzer versucht, ihn zu passieren, endet er mit einem Ausfall. Aber futuristische Technologie kommt zur Rettung, indem Sie installieren, die Sie die Lava überqueren können.

    Um einen solchen Fall zu debuggen, müssen Sie keine vollständigen Panzermodelle erstellen und vollständige Karten mit hinzugefügten Lavastandorten erstellen. Überlegen Sie einfach, welche Komponenten der Panzer mindestens benötigt, und fügen Sie dem zu testenden Spielmodul eine Entität hinzu. Das alles klingt offensichtlich, aber in der Tat stößt man auf die TheTank-Klasse, die im Konstruktor nach einer Liste von Parametern fragt: Kaliber, Geschwindigkeit, Sprite, Schussgeschwindigkeit, Mannschaftsnamen usw. Dies ist jedoch nicht erforderlich, um Lavaschnitte zu testen.
  2. Dem Beispiel aus dem vorherigen Absatz folgend, stellen wir außerdem eine große Flexibilität fest , da es mit diesem Ansatz viel einfacher ist, Features hinzuzufügen und zu entfernen.

    Ein echtes Beispiel. Das Spielszenario unserer zweiten Promo war, dass der Benutzer nach ca. 2-minütiger Ausführung des Songs in die Ziellinie stürzte und das Spiel den Countdown startete, indem er das Level erneut startete, aber Punkte sparte, wodurch der Spieler eine Pause bekam.

    Ein paar Tage vor der erwarteten Veröffentlichung steht die Aufgabe an, "die Ziellinie zu entfernen und eine halbe Stunde ununterbrochenes Spielen mit sich wiederholenden Hindernissen und Liedern zu melden". Eine globale Veränderung, die aber sehr einfach zu bewerkstelligen war - es genügte, die Essenz der Ziellinie zu entfernen und ein Bezugssystem für das Ende des Spiels hinzuzufügen.
  3. Alle Entwicklungen sind einfach zu testen . Mit dem Wissen, dass die Daten an erster Stelle stehen, können Sie beliebige Testfälle simulieren, ausführen und das Ergebnis anzeigen.
  4. In Zukunft können Sie einen Server mit dem Spielprozess verbinden, um den Status des Spiels zu überprüfen. Er wird durch den gleichen Code die gleiche Client-Eingabe ausführen und sein Ergebnis mit dem Ergebnis auf dem Client vergleichen. Wenn die Daten nicht konvergieren, ist der Client ein Betrüger oder wir haben Fehler im Spiel.


ECS in der großen Welt von Gamedev


Große Unternehmen wie Unity, Epic oder Crytek verwenden diese Vorlage in ihren Frameworks, um Entwicklern ein Tool mit einer Vielzahl von Funktionen zur Verfügung zu stellen. Ich rate Ihnen, den Bericht darüber zu lesen, wie die Gameplay-Logik in Overwatch implementiert wurde

Zum besseren Verständnis habe ich ein kleines Beispiel über Github gemacht .
Vielen Dank für Ihre Aufmerksamkeit!

Auch zum Thema:


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


All Articles