Aaron Fischer Ingenieur, Vater, Heimwerker, Problemlöser

17 Dezember, 2009

Tag 18: Menü und Game States

TL;DR:

Soweit ist unsere Spielfläche fertig. Zwar bewegt sich noch nicht all zu viel, doch bevor wir jetzt mit dem Programmieren weiter fortfahren, sollten wir uns Gedanken über die Struktur machen. Momentan steckt ziemlich viel in der draw.c und der Rest ist in main.c. Auch landen wir beim Start der Anwendung direkt auf dem Spielfeld. Ein Menü wäre wünschenswert sowie etwas mehr Ordnung im Code.

Machen wir zuerst etwas Ordnung. Hierzu möchte ich ein Konzept einführen, das viele Spiele verwenden. Bei den meisten Spielen gibt es anfangs ein (mehr oder weniger verschachteltes) Menü, dann den eigentlichen Spielscreen und in diesem meist noch einmal eine Ebene, in der sich Dinge einstellen lassen oder aber sogar eine Konsole. Diese Zustände, in dem sich das Spiel befinden kann, nennt man Game States. Jeder Game State hat eine separate Eingabe und eine separate Ausgabe. Beispielsweise kann man mit den Pfeiltasten im Hauptmenü durch das Menü wandern während die Pfeiltasten im Spielscreen einen Geschützturm lenken. Auch brauchen wir eine separate Zeichenroutine für das Hauptmenü und für die Spielfläche.

Es muss also eine Möglichkeit her, in der für jeden Zustand Eingabe, Verarbeitung und Ausgabe separat abgehandelt wird. Dies lässt sich mit einer State Machine - oder auch Zustandsautomat genannt - gut umsetzen. Bei unserem kleinen Spiel wollen wir uns vorerst auf folgende Zustände beschränken:

Game States

Wir haben ein Hauptmenü, von dem aus wir entweder das Spiel verlassen können, die Credits anschauen oder ein neues Spiel starten. Befinden wir uns im Credits-Bildschirm, können wir mit dem Zurück-Knopf wieder zum Hauptmenü zurückkehren. Wollen wir ein neues Spiel starten, werden wir in einem zweiten Menü (einem anderen Zustand) wählen können, mit wie vielen Spielern wir das Spiel spielen wollen. Mit Zurück kommen wir wieder ins Hauptmenü. Ist das Spiel einmal gestartet, können wir es mit ESC oder F10 beenden und wir erhalten noch eine Frage, ob wir wirklich beenden wollen.

Zusammengefasst müssen wir uns um fünf Zustände kümmern: Hauptmenü, Spielerwahl, Credits, das laufende Spiel und den Spiel wirklich beenden?-Dialog. Natürlich werden später noch weitere Zustände dazukommen, deshalb werden wir uns bemühen müssen, eine möglichst generische Lösung zu programmieren.

Da wir in C nicht einfach eine Zustandsklasse erstellen können und uns davon ein paar Instanzen in einer HashMap speichern können, müssen wir etwas weiter ausholen. Definieren wir uns zuerst unsere Zustände in einem enum:

enum states {
  STATE_MAINMENU,
  STATE_NUMPLAYERS,
  STATE_CREDITS,
  STATE_RUNNINGGAME,
  STATE_RLYQUIT,
  STATE_EXIT,
  MAX_STATES
};

MAX_STATES brauchen wir für die Anzahl der Zustände, STATE_EXIT ist ein Spezialzustand, mit dem wir das Spiel beenden können, er löst quasi unsere gameRunning Variable ab. Definieren wir uns weiter ein struct, das einen Spielzustand widerspiegelt. Wir haben oben gesagt, dass wir jeweils eine separate Eingabe- und Ausgabefunktion benötigen. Diese speichern wir als Funktionszeiger.

struct gameState {
  void(*drawFun)();
  void(*handleEventsFun)();
};

Anschließend erstellen wir uns eine Tabelle mit allen Zuständen. Das vordere ist jeweils die Ausgabe, das hintere die Events/Eingabe. Zudem definieren wir uns noch eine Variable, die den aktuellen Zustand enthält. Dieser weisen wir am Anfang den Zustand Hauptmenü zu, da der Startpfeil darauf zeigt.

struct gameState stateTable[] = {
    {*displayMainmenu, *eventsMainmenu},       /* STATE_MAINMENU */
    {*displayNumplayers, *eventsNumplayers},   /* STATE_NUMPLAYERS */
    {*displayCredits, *eventsCredits},         /* STATE_CREDITS */
    {*displayRunninggame, *eventsRunninggame}, /* STATE_RUNNINGGAME */
    {*displayRlyquit, *eventsRlyquit},         /* STATE_RLYQUIT */
    {*exitGame, *exitGame}                     /* STATE_EXIT */
};

enum states currentState;
currentState = STATE_MAINMENU;

Starten wir das Spiel, landen wir also im Hauptmenü-Zustand. Nun müssen wir dafür sorgen, dass die Funktionen displayMainmenu() und eventsMainmenu() aufgerufen werden. Dies machen wir in unserem Game Loop:

  while (currentState != STATE_EXIT) {
    // Check for events
    if (SDL_PollEvent(&event)) {
      // Make it possible to close the game window
      if (event.type == SDL_QUIT) currentState = STATE_EXIT;
      stateTable[currentState].handleEventsFun();
    }

    // Draw the stuff on the screen and "flip" th the next frame
    SDL_FillRect(screen, NULL, 0x000000);
    stateTable[currentState].drawFun();
    SDL_Flip(screen);
  }

Die while-Schleife wurde nun ganz schön klein. Sie läuft logischerweise so lange, bis wir uns im Zustand STATE_EXIT befinden (unser einziger Endzustand im Diagramm). Zuerst schauen wir nach, ob ein Event vorliegt. Wenn ja, rufen wir die handleEventsFun des aktuellen Zustands auf. Da wir nach dem Starten im Hauptmenü sind, ist das die Funktion eventsMainmenu(). In eventsMainmenu() kann nun auf den events-Zeiger zugegriffen werden und auf Events reagieren. Im Hauptmenü wären das beispielsweise Pfeil hoch, Pfeil runter, Enter, usw. Danach wird der Bildschirm geleert und die drawFun() des aktuellen Zustands wird aufgerufen - in unserem Fall displayMainmenu(). In dieser Funktion wird auf den screen-Pointer zugegriffen, um auf dem Bildschirm zu malen. Hier werden wir das Hauptmenü zeichnen.

In einer Objektorientierten Programmiersprache sähe dies natürlich etwas hübscher aus, doch wir haben uns ja vorgenommen, nur mit C99 zu arbeiten, also müssen wir mit dem zurechtkommen, was wir haben: enums, structs, Funktionen und Zeiger auf diese.

Kommen wir zum Menü. Hier kommt uns das Zustandsmodell recht gelegen, da wir direkt davon profitieren können. Jedes Menü ist im Grunde gleich aufgebaut: Es gibt eine Liste von Einträgen (Strings), die jeweils einen Zustandswechsel vornehmen. Also verwenden wir wieder ein struct, um die Daten zu kapseln:

struct menuItem {
  char buttonDescription[30];
  enum states targetState;
};

Damit können wir einen Menüeintrag abbilden. Ein ganzes Menü sieht dann so aus:

struct menuItem mainMenu[] = {
  {"Neues Spiel", STATE_NUMPLAYERS},
  {"Credits", STATE_CREDITS},
  {"Spiel beenden", STATE_EXIT}
};

Dies können wir direkt aus dem Zustandsdiagramm vom Anfang übernehmen. Die Transition ist die Beschriftung und der Zielzustand ist der Zustand, an dem der Pfeil endet. So können wir sämtliche Menüs definieren, die uns noch fehlen.

Da jedes Menü ein Zustand ist, hat es natürlich wieder eigene Eingaben und Ausgaben. Da aber jedes Menü gleich reagiert (Eintrag selektieren oder zum vorherigen springen) und die gleichen Ausgaben hat (Liste mit den Menüeinträgen, das aktuelle markieren), können wir hier wieder eine etwas generalisiertere Lösung angehen; wir definieren uns die Events und die Ausgaben eine Funktion mit Parametern.

Bei der Ausgabe kommt allerdings wieder etwas Neues ins Spiel. Wir müssen Text auf dem Screen zeichnen. Dies von Hand zu machen, wäre viel zu aufwändig, ein Bild nehmen und die Buchstaben daraus herausschneiden wollen wir nicht, also greifen wir auf SDL_TTF zurück, mit dem wir fertige Schriftarten einlesen und zeichnen können. Um dies zu tun, müssen wir wie bei SDL TTF initialisieren. Danach nehmen wir uns eine System-Schriftart. Hier könnte man wieder etwas optimieren und die Schriftart erst im System suchen, bevor man sie leichtfertig verwendet, wer also Lust hat, ... :)

TTF_Init();
menuFont = TTF_OpenFont("/usr/share/fonts/corefonts/verdana.ttf", 50);

void drawMenu(struct menuItem items[], int numItems) {
  SDL_Surface *text;
  SDL_Rect targetPos;

  SDL_Color colorSelected = {0, 255, 66};
  SDL_Color colorNormal = {59, 71, 62};

  for (int i=0; i<<numItems; i++) {
    targetPos.x = 200;
    targetPos.y = i*60+200;
    targetPos.w = SCREEN_WIDTH-200;
    targetPos.h = 50;
    text = TTF_RenderText_Solid(menuFont, items[i].buttonDescription,
        currentMenuActionState == i ? colorSelected : colorNormal);
    SDL_BlitSurface(text, NULL, screen, &targetPos);
  }
}

Wir nehmen unser Menü, lassen es durch eine Schleife laufen und erstellen für jeden Eintrag die Schrift auf dem text Surface. Dieses Surface mergen wir nun mit SDL_BlitSurface() in unseren Screen.

Zum Schluss noch die Sache mit den Events. Wie schon oben geschrieben, müssen wir nicht viel darüber wissen, nur um welches Menü es sich handelt, wie viele Einträge es hat und was der vorherige Zustand ist.

void handleMenuEvent(struct menuItem items[], int numItems, enum states prevState) {
  if (event.type == SDL_KEYDOWN) {
    switch (event.key.keysym.sym) {
      case SDLK_DOWN:
      case SDLK_j:
        currentMenuActionState++;
        if (currentMenuActionState >> numItems-1) currentMenuActionState = 0;
        break;
      case SDLK_UP:
      case SDLK_k:
        currentMenuActionState--;
        if (currentMenuActionState << 0) currentMenuActionState = numItems-1;
        break;
      case SDLK_RETURN:
      case SDLK_SPACE:
        currentState = items[currentMenuActionState].targetState;
        currentMenuActionState = 0;
        break;
      case SDLK_ESCAPE:
      case SDLK_BACKSPACE:
        currentMenuActionState = 0;
        currentState = prevState;
        break;
      default:
        break;
    }
  }
}

Je nachdem welche Taste gedrückt wird, reagieren wir anders darauf. Wird Pfeil runter oder j gedrückt, setzen wir den nächst unteren Eintrag auf den aktuellen Eintrag. In die andere Richtung geht es mit Pfeil nach oben und k. Drücken wir Enter oder die Leertaste, setzen wir den aktuellen Zustand auf den Zielzustand des gerade selektierten Eintrags. In die andere Richtung geht es mit ESC und Backspace, hier setzen wir den aktuellen Zustand auf den übergebenen vorherigen Zustand prevState.

In den entsprechenden display- und handleEvent- Funktionen brauchen wir jetzt nur noch die Funktion mit den entsprechenden Parametern aufzurufen. Mit Hilfe des Zustandsautomaten haben wir mit ein paar Zeilen Code ein einfaches und erweiterbares Menüsystem gebaut.

Hauptmenü

Dies war jetzt ganz schön viel auf einmal. Ich habe mit Absicht so viel in diesen Beitrag gepackt, damit ich noch ein paar andere Aspekte von OpenSource zeigen kann. Der komplette Code (also nicht nur die hier gezeigten Schnipsel) liegt wie immer auf GitHub. Übermorgen geht es um das Testen unserer Software, um Dokumentation, Bedienungsanleitung und der Umgang mit Patches, Wünschen und Anfragen von Endusern. Ich freue mich wie immer auf Kommentare und pull requests.