Sanduhr – erste Schritte mit ClanLib und C++
Die Sanduhr ist ein recht simples Beispielprogramm um erste Schritte mit der ClanLib Spiele Engine und C++ zu machen. In dieser Simulation wird eine Sanduhr implementiert. Der Sand läuft dabei dynamisch nach bestimmten Regeln langsam herunter. Der Source Code und eine Einführung wie man mit der Engine schnell und einfach das erste Beispiel programmiert. Dieser Artikel knüpft damit nahtlos an meinen ClanLib Beitrag an.
Sanduhr C++ Implementierung
Dieses Tutorial setzt voraus, dass man bereits C++ programmieren kann, aber mit ClanLib noch keine Erfahrungen hat. Das fertige Programm liefert folgendes Ergebnis:
Die grafische Ansicht im Fenster zeigt eine Sanduhr und einen Timer. Die Zeit läuft dabei hoch während der Sand von der oberen Hälfte des Uhrenglases in die unter Hälfte rieselt. Das Beispiel verwendet die ClanLib Engine, d.h. die Engine wird initialisiert, erstellt das Fenster und kümmert sich um das zeichnen des Fensterinhalts. Je nach System (Windows, Linux, MacOSX, …) übernimmt dabei die jeweilige Grafikschnittstelle (OpenGL oder DirectX) das Zeichnen der Elemente.
Source Code
Den Source Code findet ihr auf GitHub. In der DevBlog Solution fasse ich dort alle Beispiele dieses Blogs zusammen, das erste ist die Sanduhr. Der Code wurde mit der aktuellen Visual Studio IDE (2017) erstellt und sollte damit problemlos lauffähig sein. Man muss lediglich die Include und Lib Pfade der ClanLib Engine eintragen.
ClanLib
Voraussetzungen für meine Beispiele ist die ClanLib Engine. Den Source Code kann man sich von GitHub laden. Der Code beinhaltet 3 Solutions:
- Examples-vc2013.sln
die Solution mit allen Beispielprojekten von ClanLib für Visual Studio 2013. Sollte damit funktionieren, habe ich jedoch nicht getestet. - Examples-vs2015.sln
die Solution mit allen Beispielprojekten von ClanLib für Visual Studio 2015. Ich habe diese Beispiele verwendet um meinen alten ClanLib Version 2 Code auf die aktuelle Version 4 zu portieren. Die Beispiele sind praktisch um Aspekte der Engine zu lernen. Für jedes einzelne Projekt der Solution muss man die Include und Lib Verzeichnisse anpassen, danach sollte jeweils die Debug und Release Version problemlos kompilieren und ausführbar sein. - ClanLib.sln
der eigentliche Source Code der Engine. Bei mir hat dieses Projekt ohne weitere Einstellung kompiliert. Man erhält im Lib Ordner unter Win32 alle nötigen *.lib Dateien. Man muss jeweils die Debug und die Release Version bauen.
Soweit so gut. Ihr solltet nun im Projektordner einen Include und Lib Ordner finden. Diese Ordner müsst ihr in jedem ClanLib Projekt bei den zusätzlichen Include und Lib Pfaden angeben.
ClanLib beinhaltet auch ein configure.exe Programm. Ich denke das setzt für alle Beispiele die Include und Lib Pfade automatisch. Ich habe das jedoch für jedes Projekt manuell gemacht.
Erstes Beispiel: Sanduhr
Jedes ClanLib Projekt benötigt die Bibliotheksdateien der Engine. Wir müssen deshalb immer die Include und Lib Pfade eingeben. Im einfachsten Fall erstellen wir unter Visual Studio ein leeres Projekt und fügen diese Pfade in den Projekteinstellungen hinzu:
Die Pfade müsst ihr natürlich an euer System anpassen. Wenn ihr das anpasst solltet ihr den Code von GitHub bereits auf eurem System erstellen können. Die nächsten Absätze beschreiben wie der Source Code funktioniert und wie man die ClanLib Engine verwendet.
1. Header hinzufügen
In der precomp.h Datei findet ihr alle externen Header Dateien die man global für das Projekt benötigt. Das sind:
#include <ClanLib/core.h> #include <ClanLib/application.h> #include <ClanLib/display.h> #ifdef WIN32 #include <ClanLib/d3d.h> #endif #include <ClanLib/gl.h>
Je nachdem wie ihr zeichnen wollt reichen d3d oder gl – gesteuert über eine Präprozessor Anweisung für Windows. D.h. unter Windows wird Direct3D verwendet, OpenGL dient als Fallback für die restlichen Systeme.
2. Engine initialisieren
ClanLib ist vollständig objektorientiert implementiert. Das bedeutet der Einsprungspunkt vom Programm ist eine Klasse die von clan::Application erbt. Dort sollte man den Konstruktor und die update Methode implementieren. Das kann beispielsweise so aussehen:
clan::ApplicationInstance<App> clanapp; App::App() { #if defined(WIN32) && !defined(__MINGW32__) clan::D3DTarget::set_current(); #else clan::OpenGLTarget::set_current(); #endif // Set the window clan::DisplayWindowDescription desc; desc.set_title("Sanduhr"); desc.set_size(clan::Sizef(WINDOW_HEIGHT, WINDOW_WIDTH), true); desc.set_allow_resize(true); window = clan::DisplayWindow(desc); ... } bool App::update() { ... }
In der ersten Zeile wird das Objekt instanziert. Das ist nötig, damit das Progamm läuft (also immer bei der von clan::Application abgeleiteten Klasse). Im Konstruktor wird die Anwendung initialisiert. Der abgebildete Code zeigt wie das Rendertarget durch das Präprozessor Konstrukt entweder für Direct3D oder OpenGL initialisiert wird. Danach wird ein Fenster mit bestimmten Werten (Titel und Größe) initialisiert. Neben dieser Basis-Initialisierung könnt ihr für eure speziellen Bedürfnisse eigene Initialisierungmehtoden verwenden – ich rufe im Sanduhr Beispiel die init() Methode dafür auf.
Zweiter wichtiger Bestandteil neben der Initialisierung im Konstruktor ist die update Methode. Diese wird regelmäßig aufgerufen. Jedes Spiel besteht aus einer Game Loop in der wiederholt der Stand der Simulation oder des Spiels berechnet und danach gezeichnet werden. So wie in jedem Spiel wird diese Methode einmal pro Frame aufgerufen. Wie oft pro Sekunde hängt von der Last ab, die von der Berechnung und dem Zeichnen benötigt wird.
3. Individuelle Anpassungen
Noch haben wir nichts was gezeichnet werden soll. Würden wir das Programm wie oben initialisiert ohne weiteren Code ausführen wird maximal ein Fenster mit leerem Inhalt angezeigt. All das übernimmt die Engine. Was wir damit jedoch machen obliegt uns. Wir müssen nun mindestens zwei Dinge hinzufügen:
- Objekte zeichnen
Text, Pixel oder komplexere Objekte wie Bilder oder animierte Objekte. - Objekte nach einem bestimmten Muster verändern
im aktuellen Fall sind das Pixel die den Sand repräsentieren. Diese werden nach einem bestimmten Muster nach unten verschoben. Bei einem Spiel könnten das auch Panzer sein die sich zur gegnerischen Basis bewegen.
Genau das macht die Sanduhr. Optional (und für ein Spiel von essenzieller Bedeutung) sind dann noch:
- Reaktion auf Input
- Endbedingung
Ich habe im Beispiel bereits zwei Handler für Tastatur- und Mauseingaben angelegt. Das einzige was aktuell implementiert ist, ist die Leertaste und ESC. Bei einem Klick darauf wird die Simulation pausiert beziehungsweise bei ESC beendet. Eine Game Loop ist per Definition eine Enlosschleife, die man über eine Interaktion beenden kann. Dadurch ist es möglich das Programm sauber zu beenden (und bei einem Spiel eventuell den Status zu speichern).
Recht viel mehr müsst ihr weder bei einem Spiel noch bei einer Simulation implementieren. Je nach Komplexizität können das aber mehrere hundert Tausend Zeilen Code werden.
Wie kommt der Sand in die Uhr?
Die Sanduhr ist in diesem Projekt einfach in der App Klasse implementiert. Die gesamte Funktionalität wird in den drei Funktionen init(), compute() und render() dargestellt. Zusätzliche gibt es noch die Hilfsfunktionen init_pattern() und compute_pattern() welche die init und compute Logik speziell für das Pattern bereitstellen, aber dazu gleich mehr. Die irand(a, b) Hilfsfunktion liefert uns zuletzt noch eine zufällige Zahl zwischen a und b.
Die Sanduhr besteht aus 3 Elementen:
- Uhrglas
die blauen Pixel in X Form - Sand
gelbe Pixel die von der oberen Hälfte langsam in die untere Hälfte rieseln - Ausgabe der Zeit
ein Timer der die Sekunden seit Simulationsstart hochzählt
Der zentrale Speicher für die Simulation ist das drei dimensionale Array
unsigned char _sand[2][SAND_HEIGHT][SAND_WIDTH];
In diesem wird zwei mal die Sanduhr pixelweise gespeichert. Eine Sanduhr wird durch ein zwei dimensionales Array mit einer größe von SAND_HEIGHT * SAND_WIDTH repräsentiert. Diese beiden Konstanten sind standardmäßig 300*103, können von euch aber jederzeit in der globals.h geändert werden. Dort ist zum Beispiel auch die Größe des Programmfensters definiert. In der init() Methode werden alle Variablen der Simulation mit Werten initialisiert und in drei schleifen die beiden Boards erzeugt – es werden die entsprechenden Pixel gesetzt. Es gibt 3 Typen:
- Sand
ein als sand markiertes Pixel wird als gelber Pixel gerendert und entspricht dem Sand (dynamisch) - Glass
ein als glass markiertes Pixel wird als blauer Pixel gerendert und entspricht dem Glas der Sanduhr (statisch) - Empty
dieser Status entspricht Luft – dieses Pixel wird nicht gerendert. Es hat die Farbe des Hintergrunds und kann zu einem Sand Pixel werden.
Warum 2 Boards?
Die Simulation der Sanduhr funktioniert ähnlich dem Game of Life. Viele Programmierer kennen diesen Algorithmus aus der Schule. Ein Board enthält den aktuellen Status, das andere Board den zukünftigen. Die Variablen
int _board; int _nextboard;
enthalten den Index des jeweiligen Boards, in jeder compute() Iteration kommt es zu einem Tausch der beiden Indizes.
Die Simulation
In der compute() Funktion wird nun einmal pro Aufruf der update() Funktion der Engine das Board neu berechnet. Das passiert in der compute() Methode. Diese ist eigentlich recht simpel:
- in einer Schleife (zwei for schleifen) wird jeder Pixel mit der compute_pattern() Methode neu errechnet. Diese benötigt die Koordinaten des Pixels und die beiden Boards
- je nach Modus läuft die Schleife nicht bis zum letzten Pixel durch, alle 4 Durchläufe wiederholt sich der Modus
- das aktuell sichtbare Board wird getauscht (_nextboard)
- die vergangene Zeit wird im Zähler der Simulationszeit aufgerechnet
Der Modus ist nur ein kleiner Workaround, damit keine Pixel liegenbleiben. Es schafft eine kleine Variation der Muster, damit auch in Ecken liegende Pixel runter „rutschen“. Die Simulation würde auch in nur einem Modus laufen – einfach mal probieren!
Interessant ist nun eigentlich nur noch die compute_pattern() Methode. In dieser wird ein einzelnes Pixel anhand der zuvor in der init_pattern() Methode definierten Muster verändert. Wie funktioniert das? Ein Muster (Pattern) sieht beispielsweise so aus:
p_vec.push_back(Pattern(1000, 10));
Der erste Wert 1000 ist der alte Status, der zweite Wert 10 ist der neue Status. Jeder Wert entspricht den Typen der Pixel in der aktuellen Zeile und der nächsten. Der Wert 1000 bedeutet folgendes:
10
00
In der ersten Zeile befindet sich ein Sandkorn, rechts davon ein Leerer Raum. In der Zeile darunter befinden sich zwei Pixel leerer Raum. Das Resultat ist 10 … jede Zahl besteht aus 4 Stellen, fehlende Stellen müssen vorne mit 0en aufgefüllt werden – da die Zahl ein Integer ist werden führende 0en nicht gespeichert. Das Resultat ist deshalb 0010 was wie folgt aussieht:
00
10
Ich hoffe man kann sich nun vorstellen, dass das gelbe Sandkorn nun von der oberen in die untere Zeile gefallen ist. Machen wir noch ein zweites Beispiel zum Verständnis:
p_vec.push_back(Pattern(1120, 1021));
Wobei die 2 das Glas repräsentiert (siehe dazu das enum Fieldvalue in der globals.h). Das Beispiel sieht nun anders formatiert wie folgt aus. Der Ausgangszustand:
11
20
ändert sich wie folgt:
10
21
Das rechte Sandkorn ist runter gefallen, das linke ist auf dem Glas liegen geblieben. Ohne die zuvor definierten Modi würden solche Pixel für immer liegen bleiben. Wichtig ist, dass alle Pattern definiert werden, ansonsten würden manche Körner nicht verarbeitet werden. Ein Spezialfall ist 1100, bei dem würden ja beide Körner runter fallen. Damit aber ein wenig mehr Leben (Zufall) in diese wurde dieser Spezialfall in der compute_pattern() ausprogrammiert:
if (_in == 1100) { int r = irand(MIN, MAX); if (r<PROP1) _out = 11; //beide Körner fallen if (r >= PROP1 && r<PROP2) _out = 1001; //rechtes Korn fällt if (r >= PROP2 && r<PROP3) _out = 1010; //linkes Korn fällt if (r >= PROP3) _out = 1100; //Körner bleiben stecken }
Je nach zufälligen Wert fallen beide, nur eines oder gar keines der beiden Körner (weil die wo feststecken). Damit sieht der herunter rieselnde Sand viel generischer und realistischer aus. Ihr könnt gerne zum Test diesen Spezialfall auskommentieren und das entsprechende auskommentierte Muster einkommentieren.
Zeichnen
Die render() Methode ist in diesem Programm relativ langweilig, zeigt aber sehr gut an einem einfachen Beispiel wie das rendern mit der ClanLib Engine funktioniert. Zum einen wird vom Board jeder Sand un Glas Pixel mit der canvas.fill_rect Methode mit der jeweiligen Farbe gezeichnet, zum anderen die aktuelle Zeit formatiert als Text mit font.draw_text auf dem Bildschirm platziert. Ich denke man kann da recht einfach andere Farben und Koordinaten ausprobieren, die beiden Funktionen sind einfach zu bedienen.
Fazit
Das waren erste Schritte mit ClanLib und C++ in einem ewig langem Artikel. Wir haben im Studiengang einige dieser kleinen Simulationsbeispiele erstellt und das war immer sehr spannend. Fast so spannend wie das umschreiben für die neue ClanLib Version und die Dokumentation. Das bedeutet ihr dürft auf weitere so spannende Artikel hoffen. Mir macht die Entwicklung solcher Simulationen viel Spaß!
ClanLib ist eine Open Source Engine die auch auf anderen Betriebssystemen und Plattformen läuft. DieRaspberry Pi Fans unter euch könnten ja dieses Beispiel am Raspberry Pi kompilieren und ausführen. Soweit mir bekannt muss die Engine das unterstützen.
Bitte schreibt mir euer Feedback zu diesem Artikel und ob ihr weitere solcher Beiträge lesen wollt.