Spielzustand-Automaten

Aus Spieleprogrammierer-Wiki
Wechseln zu: Navigation, Suche

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 eines Spielzustand-Automaten ist es, das Spiel in einzelne Spielzustände einzuteilen - einen State. Jeder State behandelt seinen eigenen Kontext an Daten, Aktualisierungen und Visualisierungen. Diese Zustände werden von einem so genannten Zustands-Manager StateManager verwaltet. Diesem wird jeder Spielzustand registriert, so dass er verwendet werden kann. Der Zustands-Manager bietet eine Funktion zum Wechsel eines Zustandes zu einem anderen an. Bei diesem Zustandswechsel können Informationen vom Quell- zum Zielzustand übergeben werden.

Funktionsweise und Einsatz von Spielzustand-Automaten

Beim Start des Programms wird Der Zustands-Manager erstellt und alle Zustände, die der Automat einnehmen kann, registriert. Danach wird ein Zustand als aktueller festgelegt. Während des Programmablaufs wird der aktuelle Zustand kontinuierlich ausgeführt, wie Logik aktualisieren und Darstellung. Während des Programmablaufs kann der Zustand selbst entscheiden, ob und in welchen Zustand der Zustands-Manager wechseln und welche Informationen übergeben werden sollen. Dazu ein einfaches Beispielszenario:

Dem Zustands-Manager wurden die Zustände Hauptmenü, Level wählen und Hauptspiel registriert. Als aktueller Zustand wird Hauptmenü gesetzt. Während des Programmablaufes wird im Zustand Hauptmenü die Eingaben abgefragt und das Menu im Fenster dargestellt. Der Zustand erhält die Eingabe, dass ein neues Spiel gestartet werden soll und notifiziert dem Zustands-Manager, dass in den Zustand Level wählen gewechselt werden soll. Nach dem Wechsel durch den Zustands-Manager wird der Zustand Level wählen aktualisiert und durch die Wahl eines Levels und Schwierigkeitsgrad wird in den Zustand Hauptspiel gewechselt. Beim Wechsel werden vom Quellzustand die Informationen, welches Level und Schwierigkeitsgrad gewählt worden sind, zum Zielzustand weitergereicht. Dieser Empfängt diese Informationen und kann das Spiel beginnen. Am Ende des Spiels wird wieder in den Zustand Hauptmenü gewechselt.

Variante: 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.

Stack states.png

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.

Technische Umsetzung

Um Spielzustand-Automaten umzusetzen, ist es hilfreich, sich stichpunktartig zu überlegen, was alles benötigt wird:

  1. Zustands-Manager muss vorhanden sein
  2. Spielzustände erstellen
  3. Spielzustände dem Zustands-Manager registrieren
  4. aktuellen Zustand setzen
  5. Zustände ausführen
  6. Zustand wechseln mit optionaler Informationsübergabe

Bei einer objektorientierten Umsetzung bilden sich eine Klasse und eine Schnittstelle heraus: Die Klasse StateManager und die Schnittstelle State. Durch diese beiden Elemente sind Punkt 1 und 2 erledigt. Jetzt müssen diese beiden Elemente noch mit Feldern und Methoden bestückt werden, um alle Anforderungen zu erfüllen.

Spielzustände dem Zustands-Manager registrieren

Das lässt sich mit Hilfe einer öffentlichen Methode der Klasse StateManager realisieren. Die könnte RegisterState heißen und erhält als Parameter eine Instanz auf ein Objekt, welches die Schnittstelle State implementiert und eine Zeichenkette als Name des registrierten Zustandes.

Aktuellen Zustand setzen

Auch dafür bietet sich eine öffentliche Methode an, die SetCurrentState heißen könnte. Diese erhält eine Zeichenkette mit dem Namen eines registrierten Zustandes. Der Zustands-Manager sucht dann nach einem Zustand mit diesem Namen und setzt ihn als aktuellen Zustand. Das merken lässt sich durch anlegen eines privaten Feldes der Klasse StateManager realisieren. Das Feld könnte currentState heißen.

UML-Klassendiagramm eines Spielzustand-Automaten

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

Vor- und Nachteile

Erweiterungsmöglichkeiten

Meine Werkzeuge
Namensräume
Varianten
Aktionen
Navigation
Werkzeuge