Table of Contents

SectorMania Code - GameManager und Startup

Der GameManager ist nicht unbedingt das Herz des Spiels, aber de facto der Rahmen. Er ist zuständig für die Initialisierung des Spiels und entsprechend auch den Shutdown und bringt die Main Loop ans Laufen.

Startup

Der Einstiegspunkt main (bzw. später WinMain für Windows) befindet sich naheliegenderweise in main.cpp. Die Funktion hat nicht viel zu tun, im Wesentlichen erzeugt sie eine Instanz des GameManagers und bringt ihn ans Laufen. Darüber hinaus werden hier unbehandelte Exceptions abgefangen, die gemäß der Plattform dem User mitgeteilt werden und dann einen Shutdown des GameManagers auslösen. Eine Besonderheit von main ist die Schleife, in der der GameManager erzeugt und gestartet wird. Die Schleife hat die Funktion, dass das Spiel im Prinzip beliebig oft reinitialisiert, also beinahe neu gestartet werden kann. Das ist insofern interessant als dass Änderungen an den Grafiksettings in den meisten Fällen einen Neustart erfordern. Solange der User im Hauptmenu ist, kann sich das Spiel so also quasi selbst neustarten, um die neuen Einstellungen zu übernehmen (in einem laufenden Szenario wäre das aber problematisch und werden wir folglich nicht unterstützen). Das funktioniert dann so, dass die Hauptfunktion von GameManager, run(), einen Rückgabewert hat, der signalisiert, ob eine Reinitialisierung gewünscht ist oder nicht. Falls nicht, wird die Schleife verlassen und das Spiel beendet sich.

Übersicht GameManager

Ein Blick in SMGameManager.h offenbart neben ein paar wenigen #includes zunächst eine ganze Reihe von forward declarations für alle Objekte, die der GameManager nutzt. Die meisten dieser Objekte sind prinzipiell Singletons (obwohl formal derzeit nicht als solche implementiert, was ungünstig ist, dazu später mehr), von ihnen existiert also im Spiel nur jeweils eine Instanz, die der GameManager verwaltet (im Moment verwaltet er aber zu viel, auch dazu später mehr). Dann kommt die Deklaration der eigenlichen Klasse GameManager; wie man sieht, ist sie als Ogre-Singleton implementiert, was einen globalen Zugriffspunkt auf die GameManager-Instanz für alle anderen Klassen ermöglicht (via GameManager::getSingleton() ). Außerdem leitet sie von einigen Listener-Interfaces ab, die benutzt werden, um auf bestimmte Ereignisse reagieren zu können. Die geerbten Interface-Funktionen werden weiter unten im public-Abschnitt der Klasse definiert. Zunächst kommen aber die eigenen Funktionen: Neben Konstruktor und Destruktor gibt es offensichtlich das Trio initialise / run / shutdown. initialise und run werden von main aufgerufen, shutdown wird von anderen Stellen aufgerufen, wenn sich das Spiel beenden (bzw. neustarten) soll. Darüber hinaus finden sich die Utility-Funktionen getScreenWidth/Height sowie renderOneFrame, das effektiv dafür sorgt, dass Ogre einen neuen Frame rendert (wer hätte das gedacht). Schließlich gibt es eine Reihe Accessor-Funktionen, die Zugriff auf einzelne Objekte ermöglichen und größtenteils leider unglücklich sind (mehr dazu unten). Im private-Teil dann folgen einige Funktionen, die effektiv die Aufgaben des initialise/run-Paares unterteilen in Teilaufgaben. Ihre Namen dürften die Zuständigkeiten preisgeben. Schließlich folgen die Member-Variablen.

Initialisierungsphase

Der Konstruktor von GameManager setzt zunächst die ganzen Pointer auf die verwalteten Objekte auf 0, das dient dazu, dass wir im Destruktor die Pointer alle einfach löschen (delete) können, egal ob sie gerade angelegt sind oder nicht (delete auf einen Null-Pointer ist erlaubt und tut nichts). Anschließend wird unser virtuelles Dateisystem PhysFS vorbereitet und Ogre darauf vorbereitet, dass wir ein eigenes Log-System verwenden werden (deswegen muss der Ogre-LogManager hier manuell vorab erzeugt werden). Im eigentlichen Initialisierungsvorgang in initialise wird nun zunächst das eigene HTML-Log angelegt und die Konfigurationsdatei des Spiels eingelesen, dies erledigt die Klasse Configuration, deren Instanz GameManager verwaltet. Dann wird Ogre selbst initialisiert, Ogre::Root wird angelegt und das erste verfügbare Rendersystem gewählt. Dann wird PhysFS Ogre als Ressourcenquelle bekannt gemacht und ein Arbeitsverzeichnis “temp” in PhysFS angelegt (hier werden später mal Mapdateien etc. hin entpackt während des Spiels). Weiter geht's dann mit der Erzeugung des Spielfensters und der Anlegung unseres InputManagers (offensichtlich für Spielereingaben, via OIS). initScene erzeugt die Basics von Ogre, die wir fürs Rendern brauchen, also SceneManager, Camera, Viewport, und initGUI() schließlich lädt die GUI-Ressourcen und initialisiert CEGUI. Dann folgt das Anlegen des Localisation-Objekts, dies ist die Klasse, die String-Referenzen wie “MainMenuSkirmish” in die jeweils verwendete Sprache auflöst (in diesem Fall aber wohl fast immer in “Skirmish”) und damit eine Übersetzung des Spiels möglich macht. Der KeyMapper abstrahiert Tastatureingaben, so dass der Spieler verschiedene Aktionen beliebig mit Tasten belegen kann. Anschließend werden dann die Spielressourcen geladen. Der Aufruf zu initShadows() ist sehr temporär, die ganze Schattenlösung ist momentan noch mehr ein Platzhalter, bis das irgendwann wirklich finalisiert ist, am besten im Moment keine Beachtung schenken ;) Zum Schluss werden noch SoundManager und CellPartitioner (der eigentlich nicht hierhin gehört) angelegt und ein paar abschließende Initialisierungen getätigt.

Main Loop, Game States und GUI-Verwaltung

Nun kommen wir auch schon gleich zu einem ersten mehr oder minder großen Problem der aktuellen Codestruktur und damit also einem der ersten Zwischenschritte fürs Refactoring. Nachdem unser Spiel sich jetzt initialisiert hat und für den Spieler der Ladebildschirm gerade fertiggeworden ist, geht's in die Main Loop des Spiels (die Funktion run). Die hält zunächst die verstrichene Zeit in Sekunden im Auge und führt ein paar grundsätzlich nötige Updates pro Schleifendurchgang durch, im Moment ist das das Update von Input- und SoundManager, die regelmäßig ihren Zustand aktualisieren müssen. Zum Ende der Schleife wird ein neues Bild gerendert und auf Nachrichten vom Betriebssystem bzw. Window Manager geprüft. Das ist so weit, so gut, das Problem liegt in der Phase dazwischen. Es gibt prinzipiell zwei Grundzustände, in denen sich das Spiel befinden kann; entweder es ist im Menu oder es läuft ein Szenario. Die Menu-Phase kann man dann noch weiter unterteilen in Hauptmenu, Konfigurationsdialog, Mapauswahl, Multiplayerplattform, … Das Problem ist, dass der GameManager im Moment diese Zustände selbst verwaltet, man sieht in der Main Loop eine switch-Anweisung, die im Moment zwar nicht viel tut, aber eigentlich überhaupt nicht da sein dürfte. Das Problem ist, dass es auf diese Art extrem mühsam, umständlich und fehleranfällig wird, das Ganze zu erweitern. Das eigentlich korrekte Vorgehen wäre, diese Verwaltung in separate GameState-Klassen auszulagern. Es gäbe dann eine Basisklasse GameState, von der GameManager eine Instanz besäße. Dies hat dann eine Funktion process, die GameManager in der Main Loop aufruft, process liefert dabei einen Pointer auf GameState* zurück. Ist der Pointer 0, ändert sich nichts, ist er aber nicht 0, dann löscht GameManager seinen aktuellen GameState und benutzt den neu zurückgegebenen. Dadurch können die GameStates selbständig untereinander wechseln, ohne dass der GameManager etwas davon mitbekommen müsste. Man kann dann auch einfach neue GameStates schreiben, falls nötig. Die GameStates würden dann auch Funktionen von GameManager übernehmen wie z. B. aktuell startScenario, es gäbe also z. B. einen GameState ScenarioGameState, der entsprechende Initialisierungs- und Shutdownfunktionen hat und der ein Scenario verwaltet (was folglich dann der GameManager nicht mehr tun würde und effektiv also um diese Aufgabe entlastet wird). Gleichermaßen würden diese GameStates Kontrolle über die GUI bekommen; wenn ihr etwas in den Source Files browst, wird euch auffallen, dass es eine Reihe von SMGUIxxx-Dateien gibt. Ich hatte hier ursprünglich versucht, die GUI-Verwaltung ein bisschen wie Desktop-GUIs zu machen, bei der man meistens für einen Dialog eine separate Klasse hat, aber das funktioniert hier so nicht. Wie man sieht, haben viele dieser Dateien nämlich fast keinen Code und sind im Großen und Ganzen überflüssig, weil sie nur ein oder zwei Callbacks für einen Button-Druck enthalten. Die Idee wäre nun, alle diese Dateien zu entfernen und ihre Aufgaben in die passenden GameStates einzubetten.

Wenn ihr eure Arbeit am Code beginnen wollt, würde ich sagen, dass das ein guter Startpunkt wäre. Diese Restrukturierung von GameManager ist dringend nötig und zwingt euch dazu, etwas im Code querzulesen, ohne aber alle anderen Komponenten im Detail verstehen zu müssen. Ich würde dabei dann auch begleitend mit drüberschauen, bin ja meistens im IRC erreichbar. Evtl. wäre es auch günstig, im “Practical Application”-Tutorial im Ogre-Wiki nachzulesen, da gibt es afair ein Kapitel über Game States.

Accessor-Funktionen

Kommen wir nun zum zweiten Problem, den Accessor-Funktionen. Die Idee ist prinzipiell folgende, GameManager verwaltet einige Objekte, auf die anderer Code zugreifen können muss. Nehmen wir als Beispiel das Scenario (auch wenn das, wie oben erwähnt, künftig von einem GameState verwaltet werden sollte). Das Scenario ist das Kernstück eines laufenden Spiels, es verwaltet Einheiten, Terrain etc., kurz eigentlich den allgemeinen Spielablauf. Da andere Einheiten z. B. gelegentlich auf andere Einheiten zugreifen müssen, brauchen sie Zugriff auf das Scenario. Deswegen bietet GameManager momentan eine Accessor-Funktion für Scenario. Natürlich könnte man alternativ allen Objekten, die Zugriff auf das Scenario brauchen, einen Pointer zum Scenario bei der Konstruktion übergeben, aber das ist repetitiv und äußerst mühsam, also ist ein Accessor schon keine schlechte Wahl. Das Problem ist aber, dass es einerseits Unter-Accessoren gibt. Scenario z. B. wiederum besitzt ein Terrain-Objekt, welches das Terrain des Szenarios beschreibt, und mancher Code benötigt Informationen über das Terrain. Nun ist ein Aufruf von GameManager::getSingleton().getScenario()→getTerrain() nicht gerade der Inbegriff von Eleganz, wie man sich vorstellen kann. Das größere Problem resultiert allerdings aus der Abhängigkeit. Um diesen Aufruf durchführen zu können, muss die Datei, in der der Aufruf steht, sowohl “SMGameManager.h” als auch “SMScenario.h” einbinden, obwohl sie von diesen eigentlich gar nichts braucht und nur vom Terrain abhängt. Dadurch wird die Datei aber von diesen Include-Dateien mitabhängig, und die Folge ist, dass wann immer man eine noch so kleine Änderung in “SMGameManager.h” vornimmt, wird fast das ganze Projekt neukompiliert. Die Lösung ist zum Glück nicht so kompliziert und besteht einfach darin, Klassen wie Scenario auch als Ogre-Singletons zu implementieren. Dadurch werden sie nach wie vor von ihrem Besitzer erzeugt und gelöscht, aber sie erhalten automatisch einen Accessor, der in ihrer eigenen Include-Datei sitzt, z. B. also Terrain::getSingleton(). Damit wird der Aufruf etwas entschlackt und vor allem die Abhängigkeit vom GameManager entfernt. Manche Accessor-Funktionen kann man allerdings so nicht ersetzen. getSceneManager von GameManager muss man z. B. einfach hinnehmen, allerdings wird der SceneManager auch nicht so oft gebraucht, insofern ist das kein echtes Problem.