Spielzustand-Automaten
Bitte beachte, dass dieser Artikel noch unvollständig ist! Hilf mit, ihn fertigzustellen.
Näheres dazu findest du ggf. auf der Diskussionsseite. Wenn du der Meinung bist, dass der Artikel vollständig ist, kannst du diesen Hinweis entfernen.
Jeder Spieleentwickler beschäftigt sich früher oder später mit der Frage, wie man am besten mit den verschiedenen Phasen eines Spiels umgeht. Allein wenn man beim Planen schon voraus denkt, fällt einem eine logische Aufteilung auf: Hauptmenü, Charaktererstellung, Hauptspiel, Optionsmenü, Multiplayer-Einstellungen, usw. Zwischen diesen verschiedenen Teilen des Spiels soll natürlich auch problemlos gewechselt werden – und das auch in verschiedenen Kombinationen. Für dieses Problem gibt es eine gute Lösung, welche das Problem der Aufteilung mit Übergangen abstrahiert und abbildet: Spielzustand-Automaten.
Inhaltsverzeichnis |
Grundidee und Architektur
Die Grundidee der Spielzustand-Automaten lässt sich in zwei Klassen teilen: Den Zustands-Manager StateManager und eine Schnittstelle State. Jedes Spiel definiert eine Menge an Zuständen in Form von Klassen, die die Schnittstelle State implementieren. Der StateManager kennt und besitzt alle Zustände und identifiziert diese über eine ID. Der aktuelle Zustand lässt sich mit dem StateManager setzen.
Stapelverarbeitung von Zuständen
Neben dem simplen Setzen von Zuständen ist die Abfolge von Zustandswechsel ein interessantes Thema. Es bietet sich hier die Benutzung eines Stapels (Stack) an. Der StateManager besitzt einen Stapel, der eine Menge an Zuständen referenziert. Dabei gilt das LIFO-Prinzip (Last In – First Out). Mit Hilfe der Methoden PushState lässt sich ein neuer Zustand auf den Stapel packen, der auch gleichzeitig der Aktive ist. Das Gegenstück lautet PopState und nimmt den zuletzt eingefügten Zustand vom Stapel. Durch dieses Prinzip gestalten sich Zustandsübergänge durch eine Kombination aus PushState und PopState ziemlich einfach. Hier ein einfaches Beispiel:
Ein Spiel besitzt zwei Zustände: Hauptmenü und Spiel. Der StateManager hat vorerst einen leeren Stapel. Beim Starten des Programmes wird der Zustand Hauptmenü mit PushState als erster Zustand gesetzt. Nun entscheidet sich der Spieler, ein neues Spiel zu starten. Im Programm wird mit PushState der Zustand Spiel auf den Stapel gepackt. Dadurch ist Spiel der aktuelle Zustand. Nachdem der Spieler sein Spiel gespielt und verloren hat, möchte er das Spiel erneut mit einem anderen Schwierigkeitsgrad spielen und beendet das Spiel, um in das Hauptmenü zu gelangen. Dazu wird PopState aufgerufen, wodurch der Zustand Spiel vom Stapel genommen wird und Hauptmenü wieder der aktuelle Zustand ist.
Transparente Zustände
Es gibt Situationen, in denen mehrere Zustände im Stapel sichtbar sein sollen, einige sozusagen transparent sind. So könnte beispielsweise das Optionsmenü im Hintergrund noch den darunter liegenden Zustand (Hauptmenü oder das Spiel) durchscheinen lassen. Dies kann gelöst werden, indem jeder Zustand angeben kann, ob er transparent ist. Der Stapel wird dazu von oben nach unten durchlaufen, bis entweder das untere Ende erreicht ist oder ein Zustand gefunden wurde, der nicht transparent ist. Dann werden die Zustände von dieser Stelle an von unten nach oben übereinander gezeichnet, der oberste Zustand also zuletzt.
State
State definiert eine Schnittstelle für Spielzustände, die vom StateManager verwaltet werden. Für jeden konkreten Spielzustand wird eine Klasse benötigt, die die State-Schnittstelle implementiert. Die Schnittstelle enthält eine Reihe von Methoden, die beim Eintritt bestimmter Ereignisse aufgerufen werden:
- OnEnter: Der StateManager ruft diese Methode auf, wenn von einem anderen Zustand zu diesem Zustand gewechselt wird. Dadurch können z.B. Variablen zurückgesetzt, Eingangsanimationen gestartet oder ein Musikstück abgespielt werden.
- OnLeave: Bei der Einleitung eines Zustandswechsels wird, bevor der neue Zustand gesetzt wird, OnLeave vom noch aktuellen Zustand aufgerufen. Dadurch können wichtige Nacharbeiten durchgeführt werden (Musik stoppen, Ressourcen freigeben, ...).
- OnUpdate: In dieser Funktion wird die Logik des Zustands berechnet. Im Falle des Zustands Spiel werden üblicherweise KI, Animationen und Physik aktualisiert.
- OnRender: Hier findet das gesamte Zeichnen für den aktuellen Zustand statt.
StateManager
Der StateManager verwaltet alle für das Spiel möglichen Zustände. Dazu werden ihm alle Zustände durch AddState bekannt gemacht. Für den StateManager ist immer genau ein Zustand aktuell. Dieser lässt sich mit der Methode SetState festlegen.
Prinzipiell verwendet man bei Spielen getrennte Logik für Update- und Render-Funktionalität. Daher unterscheidet der StateManager auch zwischen diesen beiden Vorgängen, indem er getrennte Methoden besitzt. Diese rufen vom aktuellen Zustand OnUpdate bzw. OnRender auf.
Beispielimplementierung
State
C++
class State { public: virtual ~State() { }; virtual void OnEnter() = 0; virtual void OnLeave() = 0; virtual bool OnUpdate(float elapsedTime) = 0; virtual void OnRender() = 0; virtual std::string NextState() = 0; };
C#
public interface State { void OnEnter(); void OnLeave(); bool OnUpdate(float elapsedTime); void OnRender(); string NextState { get; } }
Java
public interface State { void onEnter(); void onLeave(); boolean onUpdate(float elapsedTime); void onRender(); String getNextState(); }
StateManager
C++
/* h-Datei: StateManager.h*/ class StateManager { private: //Speichert alle Zustände als Assoziatives Array private std::map<std::string, State*> _statesById; //Aktueller Zustand private State *_currentState; public: StateManager(); ~StateManager(); void AddState(const std::string &id, State *state); void SetState(const std::string &id); bool Update(float elapsedTime); void Render(); }; /* cpp-Datei: StateManager.cpp */ //erzeugt ein StateManager-Objekt StateManager::StateManager(): _currentState(NULL) { } //Löscht das StateManager-objekt mit allen Zuständen. StateManager::~StateManager() { for(std::map<std::string, State*>::iterator it = _statesById.Begin(); it != _statesById.end(); ++it) { delete (*it); } } //Registriert einen Zustand für den Manager an Hand der ID. Diese muss //eindeutig und nicht leer sein. void StateManager::AddState(const std::string &id, State *state) { if (id.length() == 0) throw std::exception("Id has to be a valid string"); if (state == NULL) throw std::exception("State must not be null"); std::map<std::string, State*>::iterator it = _statesById.find(id); if (it != _statesById.end()) { std::string error("State with ID "); error.append(id); error.append(" already exist."); throw std::exception(error.c_str()); } _statesById[id] = state; } //Setzt den aktuellen Zustand void StateManager::SetState(const std::string &id) { std::map<std::string, State*>::iterator nextStateIt = _statesById.find(id); //Wenn der Zustand im Manager registriert ist if (nextStateIt != _statesById.end()) { //und nicht der aktuelle ist if (*nextStateIt != _currentState) { //Rufe OnLeave() vom alten Zustand auf, falls es einen gab if (_currentState != NULL) _currentState->OnLeave(); //setze den neuen aktuellen Zustand _currentState = *nextStateIt; //Da zu einem neuen Zustand gewechselt worden ist, rufe das Eingangsereignis auf _currentState->OnEnter(); } } else //ansonsten gibt es diesen Zustand nicht! throw new InvalidOperationException(string.Format("State with Id {0} does not exist.", id)); } //Aktualisert den Spielzustand. der Rückgabewert gibt an, ob das Programm weiterläuft. //Es läuft nicht weiter, wenn der aktuelle Zustand verlassen und kein weiterer Zustand zugewiesen wird. bool StateManager::Update(float elapsedTime) { //Es muss ein Zustand gesetzt sein! if (_currentState == NULL) throw std::exception("Current state not set."); //aktualisere den Zustand und schaue, ob der Zustand eventuell verlassen werden soll bool running = _currentState->OnUpdate(elapsedTime); //wenn er verlassen werden soll und der Folgezustand gesetzt ist if (!running && !_currentState->NextState().length() > 0) { //Wechsle den Zustand SetState(_currentState->NextState()); //Programm läuft weiter return true; } // return running; } //Zeichnet den aktuellen Zustand. void StateManager::Render() { //Es muss ein Zustand gesetzt sein! if (_currentState == NULL) throw std::exception("Current state not set."); //Zeichne den aktuellen Zustand _currentState->OnRender(); } }
C#
public class StateManager { //Speichert alle Zustände als Assoziatives Array private Dictionary<string, State> _statesById; //Aktueller Zustand private State _currentState; //erzeugt ein StateManager-Objekt public StateManager() { _statesById = new Dictionary<string, State>(); } //Registriert einen Zustand für den Manager an Hand der ID. Diese muss //eindeutig und nicht leer sein. public void AddState(string id, State state) { if (string.IsNullOrEmpty(id)) throw new InvalidOperationException("Id has to be a valid string"); if (state == null) throw new ArgumentNullException("state"); if (_statesById.ContainsKey(id)) throw new InvalidOperationException(string.Format("State with ID {0} already exists.", id)); _statesById.Add(id, state); } //Setzt den aktuellen Zustand public void SetState(string id) { State nextState; //Wenn der Zustand im Manager registriert ist if (_statesById.TryGetValue(id, out nextState)) { //und nicht der aktuelle ist if (nextState != _currentState) { //Rufe OnLeave() vom alten Zustand auf, falls es einen gab if (_currentState != null) _currentState.OnLeave(); //setze den neuen aktuellen Zustand _currentState = nextState; //Da zu einem neuen Zustand gewechselt worden ist, rufe das Eingangsereignis auf _currentState.OnEnter(); } } else //ansonsten gibt es diesen Zustand nicht! throw new InvalidOperationException(string.Format("State with Id {0} does not exist.", id)); } //Aktualisert den Spielzustand. der Rückgabewert gibt an, ob das Programm weiterläuft. //Es läuft nicht weiter, wenn der aktuelle Zustand verlassen und kein weiterer Zustand zugewiesen wird. public bool Update(float elapsedTime) { //Es muss ein Zustand gesetzt sein! if (_currentState == null) throw new InvalidOperationException("Current state not set."); //aktualisere den Zustand und schaue, ob der Zustand eventuell verlassen werden soll bool running = _currentState.OnUpdate(elapsedTime); //wenn er verlassen werden soll und der Folgezustand gesetzt ist if (!running && !string.IsNullOrEmpty(_currentState.NextState)) { //Wechsle den Zustand SetState(_currentState.NextState); //Programm läuft weiter return true; } // return running; } //Zeichnet den aktuellen Zustand. public void Render() { //Es muss ein Zustand gesetzt sein! if (_currentState == null) throw new InvalidOperationException("Current state not set."); //Zeichne den aktuellen Zustand _currentState.OnRender(); } }
Java
import java.util.HashMap; public class StateManager { // Speichert alle Zustände als Assoziatives Array private HashMap<String, State> _statesById; // Aktueller Zustand private State _currentState; // erzeugt ein StateManager-Objekt public StateManager() { _statesById = new HashMap<String, State>(); } // Registriert einen Zustand für den Manager an Hand der ID. Diese muss // eindeutig und nicht leer sein. public void AddState(String id, State state) { if (id == null || id.equals("")) throw new IllegalArgumentException("Id has to be a valid string"); if (state == null) throw new NullPointerException("state"); if (_statesById.containsKey(id)) throw new IllegalArgumentException("State with ID " + id + " already exists."); _statesById.put(id, state); } // Setzt den aktuellen Zustand public void SetState(String id) { State nextState = _statesById.get(id); // Wenn der Zustand im Manager registriert ist if (nextState != null) { // und nicht der aktuelle ist if (nextState != _currentState) { // Rufe OnLeave() vom alten Zustand auf, falls es einen gab if (_currentState != null) { _currentState.onLeave(); } // setze den neuen aktuellen Zustand _currentState = nextState; // Da zu einem neuen Zustand gewechselt worden ist, rufe das Eingangsereignis auf _currentState.onEnter(); } } else { // ansonsten gibt es diesen Zustand nicht! throw new IllegalArgumentException("State with Id " + id + " does not exist."); } } // Aktualisert den Spielzustand. der Rückgabewert gibt an, ob das Programm weiterläuft. // Es läuft nicht weiter, wenn der aktuelle Zustand verlassen und kein weiterer Zustand zugewiesen wird. public boolean Update(float elapsedTime) { // Es muss ein Zustand gesetzt sein! if (_currentState == null) throw new IllegalArgumentException("Current state not set."); // aktualisere den Zustand und schaue, ob der Zustand eventuell verlassen werden soll boolean running = _currentState.onUpdate(elapsedTime); // wenn er verlassen werden soll und der Folgezustand gesetzt ist String nextState = _currentState.getNextState(); if (!running && (nextState == null || nextState.equals(""))) { // Wechsle den Zustand SetState(nextState); // Programm läuft weiter return true; } // return running; } // Zeichnet den aktuellen Zustand. public void Render() { // Es muss ein Zustand gesetzt sein! if (_currentState == null) throw new IllegalArgumentException("Current state not set."); // Zeichne den aktuellen Zustand _currentState.onRender(); } }