Durch die Implementierung gängiger Entwurfsmuster der Spieleprogrammierung in Ihrem Unity-Projekt können Sie effizient eine saubere, geordnete und lesbare Codebasis erstellen und pflegen. Entwurfsmuster reduzieren nicht nur das Refactoring und den Zeitaufwand für Tests, sie beschleunigen auch die Onboarding- und Entwicklungsprozesse und tragen zu einer soliden Grundlage für die Weiterentwicklung Ihres Spiels, Ihres Entwicklungsteams und Ihres Unternehmens bei.
Betrachten Sie Entwurfsmuster nicht als fertige Lösungen, die Sie kopieren und in Ihren Code einfügen können, sondern als zusätzliche Tools, die Ihnen bei richtiger Verwendung beim Erstellen größerer, skalierbarer Anwendungen helfen können.
Auf dieser Seite wird das State-Muster erläutert und wie es die Verwaltung Ihrer Codebasis vereinfachen kann.
Der Inhalt hier basiert auf dem kostenlosen E-Book „ Verbessern Sie Ihren Code mit Spielprogrammierungsmustern“, das bekannte Entwurfsmuster erläutert und praktische Beispiele für deren Verwendung in Ihrem Unity-Projekt bietet.
Weitere Artikel der Reihe zu Entwurfsmustern für die Unity-Spielprogrammierung sind im Unity Best Practices Hub verfügbar. Alternativ können Sie auch auf die folgenden Links klicken:
Stellen Sie sich vor, Sie konstruieren einen spielbaren Charakter. In einem Moment kann es sein, dass die Figur auf dem Boden steht. Bewegen Sie den Controller und es scheint, als würde er laufen oder gehen. Drücken Sie die Sprungtaste und die Figur springt in die Luft. Ein paar Frames später landet es und nimmt wieder seine ruhende Standposition ein.
Die Interaktivität von Computerspielen erfordert die Verfolgung und Verwaltung vieler Systeme, die sich zur Laufzeit ändern. Wenn Sie ein Diagramm zeichnen, das die verschiedenen Zustände Ihres Charakters darstellt, könnten Sie etwa wie im Bild oben stehend enden:
Es ähnelt einem Flussdiagramm, weist jedoch einige Unterschiede auf:
- Es besteht aus einer Reihe von Zuständen (Ruhezustand/Stehen, Gehen, Laufen, Springen usw.) und zu einem bestimmten Zeitpunkt ist nur ein aktueller Zustand aktiv.
- Jeder Zustand kann basierend auf den Bedingungen zur Laufzeit einen Übergang in einen anderen Zustand auslösen.
- Wenn ein Übergang stattfindet, wird der Ausgabezustand zum neuen aktiven Zustand.
Dieses Diagramm veranschaulicht etwas, das als Finite-State-Machine (FSM) bezeichnet wird. Ein typischer Anwendungsfall für ein FSM in der Spieleentwicklung besteht darin, den internen Zustand einer Requisite oder eines „Spielakteurs“, beispielsweise der spielbaren Figur, zu verfolgen. Es gibt viele Anwendungsfälle für einen FSM in der Spieleentwicklung, und wenn Sie über Erfahrung mit der Entwicklung eines Projekts in Unity verfügen, haben Sie wahrscheinlich bereits einen FSM im Kontext der Animation State Machines in Unity eingesetzt.
Ein FSM wird durch eine Liste seiner Zustände definiert. Es gibt einen Anfangszustand mit Bedingungen für jeden Übergang. Ein FSM kann sich zu einem beliebigen Zeitpunkt in genau einem von einer endlichen Anzahl von Zuständen befinden, mit der Möglichkeit, als Reaktion auf externe Eingaben, die zu einem Übergang führen, von einem Zustand in einen anderen zu wechseln.
Das State-Entwurfsmuster hingegen definiert eine Schnittstelle, die einen Zustand darstellt, und eine Klasse, die diese Schnittstelle für jeden Zustand implementiert. Der Kontext oder die Klasse, die ihr Verhalten basierend auf dem Status ändern muss, enthält einen Verweis auf das aktuelle Statusobjekt. Wenn sich der interne Status des Kontexts ändert, wird einfach die Referenz auf das Statusobjekt aktualisiert, sodass es auf ein anderes Objekt zeigt. Dadurch ändert sich dann das Verhalten des Kontexts.
Das State-Muster ähnelt dem FSM darin, dass es ebenfalls die Verwaltung unterschiedlicher Zustände und den Übergang zwischen ihnen ermöglicht. Ein FSM wird jedoch normalerweise mithilfe einer Switch-Anweisung implementiert, während das State-Entwurfsmuster eine Schnittstelle definiert, die einen Status darstellt, und eine Klasse, die diese Schnittstelle für jeden Status implementiert.
Das Zustandsmuster wird häufig in der Spieleentwicklung verwendet und kann eine effektive Möglichkeit sein, die verschiedenen Zustände eines Spiels zu verwalten, z. B. ein Hauptmenü, einen Spielzustand und einen Game-Over-Zustand.
Sehen wir uns das Zustandsmuster anhand des Beispiels im folgenden Abschnitt in Aktion an.
Auf Github ist ein Demoprojekt verfügbar, das den Beispielcode in diesem Abschnitt bereitstellt.
Eine vereinfachte Möglichkeit, ein grundlegendes FSM im Code zu beschreiben, könnte etwa wie das folgende Beispiel aussehen, das eine Enumeration und eine Switch- Anweisung verwendet.
Zuerst definieren Sie eine Enumeration PlayerControllerState , die aus drei Zuständen besteht: Leerlauf, Gehen und Springen.
Anschließend wird switch als bedingte Anweisung in der Update-Schleife verwendet, um zu testen, in welchem Status Sie sich gerade befinden. Je nach Status können Sie die entsprechenden Funktionen aufrufen, um das jeweilige Verhalten auszuführen.
Dies kann funktionieren, das PlayerController-Skript kann jedoch schnell unübersichtlich werden, insbesondere da Sie die Bedingungen für den Übergang zwischen den Zuständen formulieren müssen. Die Verwendung einer Switch- Anweisung zum Verwalten des Status eines Spiels mit einem Skript gilt nicht als bewährte Methode, da dies zu komplexem und schwer zu wartendem Code führen kann. Mit zunehmender Anzahl von Zuständen und Übergängen kann die Switch- Anweisung umfangreich und schwer verständlich werden.
Darüber hinaus wird das Hinzufügen neuer Zustände oder Übergänge erschwert, da Änderungen an der Switch- Anweisung vorgenommen werden müssen. Das State-Muster hingegen ermöglicht ein modulareres und erweiterbareres Design, wodurch das Hinzufügen neuer Zustände oder Übergänge einfacher wird.
Lassen Sie uns das Statusmuster erneut implementieren, um die Logik des PlayerControllers neu zu organisieren. Dieses Codebeispiel ist auch im auf Githubgehosteten Demoprojekt verfügbar.
Laut der ursprünglichen Gang of Fourlöst das Zustandsentwurfsmuster zwei Probleme:
- Ein Objekt sollte sein Verhalten ändern, wenn sich sein interner Zustand ändert.
- Zustandsspezifisches Verhalten wird unabhängig definiert. Das Hinzufügen neuer Zustände hat keine Auswirkungen auf das Verhalten vorhandener Zustände.
Im vorherigen Codebeispiel kann die Klasse UnrefactoredPlayerController Statusänderungen verfolgen, löst jedoch nicht das zweite Problem. Sie möchten die Auswirkungen auf vorhandene Status minimieren, wenn Sie neue hinzufügen. Stattdessen können Sie einen Status als Objekt kapseln.
Stellen Sie sich vor, Sie strukturieren jeden der Zustände in Ihrem Beispiel wie im obigen Diagramm. Hier geben Sie den entsprechenden Zustand ein und schleifen jeden Frame, bis eine Bedingung dazu führt, dass der Kontrollfluss beendet wird. Mit anderen Worten: Sie kapseln den spezifischen Status mit einem Eintrag, einer Aktualisierung und einem Ausgang.
Um das obige Muster zu implementieren, erstellen Sie eine Schnittstelle namens IState. Jeder konkrete Zustand in Ihrem Spiel implementiert dann die Schnittstelle gemäß dieser Konvention:
- Ein Eingang: Diese Logik wird beim ersten Eintritt in den Status ausgeführt.
- Aktualisieren: Diese Logik wird in jedem Frame ausgeführt (manchmal auch „Execute“ oder „Tick“ genannt). Sie können die Update-Methode wie MonoBehaviour weiter segmentieren, indem Sie ein FixedUpdate für die Physik, LateUpdate usw. verwenden. Jede Funktion im Update führt jeden Frame aus, bis eine Bedingung erkannt wird, die eine Statusänderung auslöst.
- Ein Ausgang: Der Code wird hier ausgeführt, bevor der Zustand verlassen und in einen neuen Zustand übergegangen wird.
Sie müssen für jeden Status eine Klasse erstellen, die IStateimplementiert. Im Beispielprojekt wurde für WalkState, IdleStateund JumpStateeine eigene Klasse eingerichtet.
Eine andere Klasse, StateMachine.cs, verwaltet dann, wie der Kontrollfluss in die Zustände eintritt und sie verlässt. Mit den drei Beispielzuständen könnte die Zustandsmaschine wie im folgenden Codebeispiel aussehen.
Um dem Muster zu folgen, verweist die Zustandsmaschine für jeden von ihr verwalteten Zustand auf ein öffentliches Objekt (in diesem Fall walkState, jumpStateund idleState). Da die Zustandsmaschine nicht von MonoBehaviour erbt, verwenden Sie zum Einrichten jeder Instanz einen Konstruktor.
Sie können dem Konstruktor alle benötigten Parameter übergeben. Im Beispielprojekt wird in jedem Status auf einen PlayerController verwiesen. Sie verwenden dies dann, um jeden Status pro Frame zu aktualisieren (siehe das IdleState-Beispiel unten).
Beachten Sie Folgendes zum Zustandsmaschinenkonzept:
- Mit dem Serializable-Attribut können Sie StateMachine.cs (und seine öffentlichen Felder) im Inspector anzeigen. Ein anderes MonoBehaviour (z. B. ein PlayerController oder EnemyController) kann die Zustandsmaschine dann als Feld verwenden.
- Die CurrentState- Eigenschaft ist schreibgeschützt. Die StateMachine.cs selbst legt dieses Feld nicht explizit fest. Ein externes Objekt wie der PlayerController kann dann die Initialisierungsmethode aufrufen, um den Standardstatus festzulegen.
- Jedes Statusobjekt bestimmt seine eigenen Bedingungen für den Aufruf der Methode TransitionTo zum Ändern des aktuell aktiven Status. Sie können beim Einrichten der StateMachine-Instanz alle erforderlichen Abhängigkeiten (einschließlich der Zustandsmaschine selbst) an jeden Zustand übergeben.
Im Beispielprojekt enthält der PlayerController bereits einen Verweis auf die StateMachine, sodass Sie nur einen Player- Parameter übergeben.
Jedes Statusobjekt verwaltet seine eigene interne Logik und Sie können so viele Status erstellen, wie Sie zum Beschreiben Ihres Spielobjekts oder Ihrer Komponente benötigen. Jeder erhält seine eigene Klasse, die IStateimplementiert. Gemäß den SOLID- Prinzipien hat das Hinzufügen weiterer Zustände nur minimale Auswirkungen auf zuvor erstellte Zustände.
Hier ist ein Beispiel für den IdleState.
Ähnlich wie beim Skript StateMachine.cs wird der Konstruktor verwendet, um das PlayerController-Objekt zu übergeben. Dieser Player enthält einen Verweis auf die State Machine und alles andere, was für die Update-Logik benötigt wird. Der IdleState überwacht die Geschwindigkeit oder den Sprungzustand des Charakter-Controllers und ruft dann entsprechend die TransitionTo- Methode der Zustandsmaschine auf.
Sehen Sie sich auch das Beispielprojekt für die WalkState- und JumpState- Implementierung an. Anstatt eine große Klasse zu haben, die ihr Verhalten wechselt, verfügt jeder Status über seine eigene Aktualisierungslogik, sodass sie unabhängig voneinander funktionieren können.
Das Zustandsmuster kann Ihnen dabei helfen, die SOLID-Prinzipien einzuhalten, wenn Sie die interne Logik für ein Objekt einrichten. Jeder Zustand ist relativ klein und verfolgt nur die Bedingungen für den Übergang in einen anderen Zustand. Gemäß dem Open-Closed-Prinzip können Sie weitere Zustände hinzufügen, ohne vorhandene Zustände zu beeinträchtigen, und umständliche Switch- oder If- Anweisungen in einem monolithischen Skript vermeiden.
Sie können die Funktionalität auch erweitern, um Statusänderungen an externe Objekte weiterzugeben. Möglicherweise möchten Sie Ereignisse hinzufügen (siehe Beobachtermuster). Durch ein Ereignis beim Eintreten oder Verlassen eines Zustands können die entsprechenden Listener benachrichtigt werden und zur Laufzeit eine Reaktion veranlassen.
Wenn Sie andererseits nur wenige Staaten verfolgen müssen, kann die zusätzliche Struktur übertrieben sein. Dieses Muster ist möglicherweise nur dann sinnvoll, wenn Sie erwarten, dass Ihre Zustände eine gewisse Komplexität erreichen. Wie bei jedem anderen Entwurfsmuster müssen Sie die Vor- und Nachteile basierend auf den Anforderungen Ihres jeweiligen Spiels abwägen.
Fortgeschrittenere Ressourcen für die Programmierung in Unity
Das E-Book „Verbessern Sie Ihren Code mit Spielprogrammierungsmustern“ bietet weitere Beispiele für die Verwendung von Entwurfsmustern in Unity.
Alle erweiterten technischen E-Books und Artikel zu Unity sind im Best Practices- Hub verfügbar. Die E-Books sind auch auf der Seite mit den erweiterten Best Practices in der Dokumentation verfügbar.