Inhalt
Einleitung
Map Klasse
Kachelnbasierende Kollisionserkennung
Maps laden
Fazit
In diesem Teil des Tutorials werde ich zeigen, wie man einen Spieler über eine Tilemap bewegen kann. Zudem werden wir uns anschauen, wie eine einfache Kollisionserkennung funktioniert und wie man eine Map aus einer Textdatei lädt. Um Übersicht zu bewahren, werden ich auf Objektorientierung zurückgreifen und für unsere Map eine Klasse schreiben.
Bevor wir anfangen die Klasse zu schreiben, sollte wir uns überlegen, was sie alles beinhalten soll. Dazu schauen wir uns einfach das dritte Beispiel aus dem vorherigen Tutorial an, genau gesagt die Main.cpp. Gehen wir die Datei von oben nach unten durch, so wird schnell klar, welche Methoden und Attribute unbedingt gebracht werden:
Methoden:
- Initialisieren (Tilegröße festlegen ect. )
- Laden (Map-Feld mit Informationen füllen)
- Sprites erstellen ( Sprites erstellen und Texturen laden)
- Zeichnen ( Map zeichnen)
Attribute:
- Tilebreite und Höhe (in Pixeln)
- Mapbreite und Höhe ( in Tiles-Anzahl)
- zweidimensionales Map-Feld
- Sprite-Feld für alle Sprites
Zusätzlich schreiben wir eine weitere Methode, die uns die Anzahl der verwendeten Sprites in einer Map liefert.
Der Inhalt wäre damit festgelegt. Als nächstes sollten wir uns überlegen welche Datentypen unsere Attribute haben werden. Bei Breite und Höhe Attributen ist das klar – da muss Integer hin.
Zum verwalten von Tiles haben wir bisher ein statisches zweidimensionales Integer-Feld genutzt. Die Größe der Map war damit fest und nicht mehr veränderbar. Wir wollen aber eine Map aus einer Datei laden und da wissen wir nicht, wie groß diese sein wird, d. h. wir brauchen ein dynamisches Feld – ein Feld bei dem die Anzahl der Elemente veränderbar ist. Dazu greifen wir auf die STL -Bibliothek zurück, welche ein Bestandteil von C++ ist. STL stellt uns eine klasse std::vector bereit, die eindimensionales Feld darstellt.
// ein zweidimensionales Integer-Feld std::vector< std::vector<int > > m_map;
Für die Sprites brauchen wir auch ein dynamisches Feld:
// Feld mit Sprite-Zeigern std::vector< CSprite* > m_sprite;
Jetzt können wir unsere Klasse deklarieren. Nach dem schon alles festgelegt wurde ist es nicht allzu schwer. Ich greife hier mal auf englische Begriffe zurück, schaden kann es nicht (auch für mich nicht ;)).
// Tilemap-Klasse class Tilemap { public: Tilemap(void); ~Tilemap(); // map initialisieren bool Init(LPDIRECT3DDEVICE9 lpDevice); // map laden bool Load(const char* path); // map zeichnen void Draw(void); // die map größe in tiles-anzahl zurückgeben unsigned int GetWidth(void) const { return m_map_width; } unsigned int GetHeight(void) const { return m_map_height; } // Gibt Anzahl der Sprites zurück, die in der Map verwendet werden size_t GetSpriteCount(void) const { return m_sprite.size(); } private: // map größe in tiles anzahl unsigned int m_map_width; unsigned int m_map_height; // tile größe unsigned int m_tile_width; unsigned int m_tile_height; // 2-Dimensionales Feld mit Sprite-Nummern std::vector< std::vector<int > > m_map; // Feld mit Sprite-Zeigern std::vector< CSprite* > m_sprite; // Direct3D 9 Device LPDIRECT3DDEVICE9 m_lpDevice; };
Die Definition ist auch nicht weiter kompliziert, da alte Routinen aus Beispiel 3 verwendet werden.
// Konstruktor Tilemap::Tilemap(void) { m_map_width = 0; m_map_height = 0; m_tile_width = 0; m_tile_height = 0; m_map.clear(); } // Destruktor - alle Sprites löschen, da diese dynamisch erstellt wurden Tilemap::~Tilemap() { std::vector< CSprite* >::iterator it; for(it = m_sprite.begin(); it != m_sprite.end(); it++) { delete *it; it = m_sprite.erase(it); } } // map initialisieren bool Tilemap::Init(LPDIRECT3DDEVICE9 lpDevice) { // Zeiger prüfen if(lpDevice == NULL) return false; m_lpDevice = lpDevice; return true; } // Map laden bool Tilemap::Load(const char* path) { // Hier kommt später noch was rein // In jetzigem Stadium fehlt die Lade-Routine, // die später genau beschrieben wird. // Stattdessen wird hier, um die Klasse zu testen // eine 10x10 Map erstellt, die nur aus Sprites mit Index Null besteht (Boden) // Es werden zwei Sprites erstellt CSprite* sp1 = new CSprite; CSprite* sp2 = new CSprite; sp1->Create(m_lpDevice, L"Texturen/boden.png"); sp2->Create(m_lpDevice, L"Texturen/wand.png"); // Die Sprites werden in das dynamische Array eingetragen m_sprite.push_back(sp1); m_sprite.push_back(sp2); m_map_width = 10; m_map_height = 10; m_tile_width = 40; m_tile_height = 40; // zufallsgenerator initialisieren srand(GetTickCount()); // eine zeile aus Boden erstellen std::vector<int> temp; for(unsigned int j = 0; j < m_map_width; j++) { temp.push_back(0); } // Jetzt die oben erstellte Zeile m_map_height-Mal in die Map einfügen for(unsigned inti = 0; i < m_map_height; i++) { m_map.push_back(temp); } return true; } // Map zeichnen void Tilemap::Draw(void)> { // alle spalten durchlaufen for(unsigned int i = 0; i < m_map_height; i++) { // alle zeilen durchlaufen for(unsigned int j = 0; j < m_map_width; j++) { // je nach Sprite-Nummer in der Map, // andere Grafik zeichnen int ground_sprite_nr = m_map.at(i).at(j); // wenn kleiner null (-1), dann wird kein Sprite gezeichnet if(ground_sprite_nr < 0) continue; // Grundlayer zeichnen m_sprite[ ground_sprite_nr ]->SetPosition(j*m_tile_width, i*m_tile_height); m_sprite[ ground_sprite_nr ]->Draw(); } } }
Beispiel 4 herunterladen: Tilemap Beispiel 4
Kachelnbasierende Kollisionserkennung
Bis jetzt haben wir für unsere Tilemap ein Integer-Feld benutzt. Ein Integer-Wert kann nur eine Information speichern (dezimal gesehen) und in unserem Fall war das die Sprite-Nummer. Wir können in unseren Map also keine Informationen für Kollision verwalten. Wir brauchen ein Datentyp, welcher viele Informationen beinhalten kann. Dazu schreiben wir uns eine Datenstruktur.
Unsere Datenstruktur soll beinhalten:
- Sprite-Nummer für Grundebene : long
- Kollisionsvariable. Kann man das Tile betreten? : bool
In C++ kann man die Struktur auch als eine Klasse schreiben. Die Attribute sollten in diesem Fall besser alle public sein. Konstruktor sollte man auch nicht vergessen.
class Tile { public: // standard konstruktor Tile(void) { GroundLayer = 0; Collision = false; } // attribute long GroundLayer; bool Collision; };
Diese Miniklasse kommt in die Tilemap.h-Datei oberhalb der Tilemap-Klasse und wird uns als Datentyp für unsere Map dienen. Man muss jetzt die alte Deklaration des Map-Feldes, also…
// 2-dimensionales Feld mit Sprite-Nummern std::vector< std::vector< int > > m_map;
durch diese Zeile ersetzen:
// 2-Dimensionales Feld mit Tiles std::vector< std::vector< Tile > > m_map;
Jetzt müssen noch ein paar Zeilen in den Methoden abgeändert werden, da diese ja darauf ausgerichtet waren int als Map-Datentyp zu verwalten.
In der Load-Methode müssen folgende Zeilen umgeschrieben werden.
// eine zeile aus "Boden" erstellen std::vector<int> temp; for(unsigned int j = 0; j < m_map_width; j++) { temp.push_back(0); }
Der einzige Unterschied besteht darin, dass wir nicht eine Zahl sondern ein Tile in den Vector einfügen. Der Rest bleibt gleich.
// eine zeile aus Boden erstellen std::vector< Tile > temp; for(unsigned int j = 0; j < m_map_width; j++) { Tile tile; // neus Tile erstellen tile.Collision = false;// Tile soll betretbar sein tile.GroundLayer = 0; // 0 = erstes Sprite = Boden temp.push_back(tile); // das erstellte Tile am Ende des Vector einfügen }
Als nächstes wird folgende Zeile in der Draw-Methode umgeschrieben.
int ground_sprite_nr = m_map.at(i).at(j);
Hier gibt uns der rechte Teil nicht nur eine Zahl sondern gleich eine Klasse vom Typ Tile zurück.
// zuerst das aktuelle Tile holen Tile tile = m_map.at(i).at(j); // und dann die Sprite-Nr aus dem Tile auslesen int ground_sprite_nr = tile.GroundLayer; Es geht auch kürzer. int ground_sprite_nr = m_map.at(i).at(j);
So, damit ist die Operation beendet :) Natürlich wollen Sie sich auch überzeugen, dass es funktioniert.
Zum Test ändern wir ein Tile in der Mitte des Feldes. Genau gesagt wird die Grafik geändert und die Kollision auf true gesetzt. Dazu missbrauchen wir wieder die Load-Methode. Folgender Code kommt ganz unten in die Load-Methode rein.
// Grafik auf "Wand" ändern m_map.at(4).at(4).GroundLayer = 1; // Tile nicht begehbar machen m_map.at(4).at(4).Collision = true;
Wenn Sie jetzt das Programm ausführen, sollten Sie folgendes Bild sehen:
Testmap
Das Tile an der Position Y=4 X=4 ist jetzt nicht betretbar, doch das können wir so nicht sehen… Lassen wir doch einen kleinen Helden dies überprüfen ;)
Für unseren Helden brauchen wir ein Sprite.
// Über der Hauptschleife CSprite held; held.Create(d3d.GetDevice(), L"Texturen/held.png"); held.SetPosition(0,0); // In der Hauptschleife, nach dem Map gezeichnet wurde held.Draw();
Als nächstes benötigen wir eine zusätzliche Methode in der Map-Klasse, die aber nicht weiter schwer ist. Sie gibt lediglich ein Tile an der Position X- und Y- zurück.
// Gibt ein Tile zurück Tile GetTile(unsigned int x, unsigned int y) const { return m_map.at(y).at(x); }
Und als Letztes müssen wir die Instanzen der Map und des Held-Sprites auf eine globale Ebene verlegen. Das ist deswegen wichtig, da wir in der Funktion WindowFunc auf diese Instanzen zugreifen müssen. Schauen wir uns diese Funktion an.
// Diese Funktion wird von Windows aufgerufen, wenn eine Nachricht // für Ihr Programm vorliegt LRESULT CALLBACK WindowFunc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { // testen, um welche Nachricht es sich handelt switch(msg) { case WM_KEYDOWN: switch(wParam) { case VK_ESCAPE: PostQuitMessage(0); break; case VK_DOWN: // held will nach unten break; case VK_UP: // held will nach oben break; case VK_LEFT: // held will nach links break; case VK_RIGHT: // held will nach rechts break; case VK_F12: PostQuitMessage(0); break; } } // Wenn wir uns nicht um die Nachricht gekümmert haben wird sie // an die Standardnachrichtenverarbeitung von Windows geschickt return (DefWindowProc(hwnd, msg, wParam, lParam)); }
Wenn die Taste „Pfeil nach Oben“ gedrückt wird, dann wird von Windows an das Programm eine Nachricht WM_KEYDOWN mit den Parameter VK_UP gesendet. Diese Nachrichten werden durch WindowFunc aufgefangen und dort verarbeitet.
Das bewegen des Helden läuft immer in mehreren Schritte ab:
- Bestimme die gewünschte Position.
- Schaue ob die angestrebte Position nicht außerhalb der Kartengrenzen liegt.
- Schaue ob die angestrebte Position begehbar ist.
- Wenn angestrebte Position nicht begehbar ist oder aus außerhalb der Grenzen liegt, dann bleib stehen, ansonsten gehe auf gewünschte Position.
Die Implementierung sollte keine Schwierigkeiten bereiten. Um die Übersicht nicht zu verlieren habe ich den Code in eine Funktion gepackt. Für eine Bewegung nach unten könnte der Code so aussehen:
void BewegeHeldNachUnten(void) { // gewünschte Position in der Map berechnen // wir teilen Sprite durch 40, weil das Sprite sich immer in 40pixel Schritten bewegt unsigned int x = held.GetPositionX()/40; unsigned int y = held.GetPositionY()/40 + 1; // gewünschte Position liegt innerhalb der Map? if(y < tilemap.GetHeight()) { // Das Tile an der gewünschter Position ist begehbar? if(tilemap.GetTile(x, y).Collision == false) { // Setze den Helden an Gewünschte Position held.SetPosition(x*40, y*40); } } }
Um diese Fuktion zu testen muss sie oberhalb der WindowFunc-Funktion eingefügt und in der WindowFunc-Funktion aufgerufen werden werden.
case VK_DOWN: // held geht nach unten BewegeHeldNachUnten(); break;
So, jetzt kann unser Held nach unten laufen und bleibt automatisch stehen, wenn die Map-Grenze erreicht ist. Nach dem gleichen Schema werden die restlichen drei Funktionen implementiert und die WindowFunc-Funktion erweitert.
Beispiel 5 herunterladen: Tilemap Beispiel 5
Damit wäre unser Testlevel fertig. Unser kleiner Held kann überall auf der Karte rumlaufen, bleibt aber vor den Kartengrenzen und nicht begehbaren Tiles stehen.
Im Folgendem schauen wir uns an, wie man ein Level aus einer Datei laden kann. Wenn Sie danach vorhaben ein kleines Spielchen im Stil von Pacman oder Snake zu programmieren, dann steht euch schon fast alles Nötige zur Verfügung. Etwas anpassen und erweitern und schon hat man was, auf was man stolz sein kann ;)
Bevor wir was laden können, müssen wir wissen, wie die Daten in der Map-Datei angeordnet sind. Mit anderen Worten das Format der Map-Datei muss bekannt sein.
Nun ja, bis jetzt haben wir noch kein Format und weil wir hier was lernen möchten, erstellen wir einfach ein eigenes Format, welches an unsere Bedürfnisse angepasst ist.
Wie immer kommt eine Überlegungsphase. Wir schreiben auf, was alles benötigt wird. Am besten die Attribute in der Tilemap anschauen und schon hat man die Hälfte des Problems gelöst:
- Maphöhe
- Mapbreite
- Tilehöhe
- Tilebreite
Es muss auch bekannt sein, welche Sprites (genauer die dazugehörigen Texturen) in der Map verwendet werden. Also brauchen wir die Texturpfade.
- Spritepfad 1
- Spritepfad 2
- …
Um die Pfade leichter aus der Datei zu lesen, ist es sinnvoll zu wissen wie viele es sind. Also muss in der Map-Datei vermerkt werden, wie viele Spritepfade zu lesen sind. Natürlich muss jedem Sprite auch eine Nummer zugeordnet sein. Dazu nehmen wir einfach die Reihenfolge der Sprite in der Map-Datei.
Als letzter Schritt werden die Tile-Informationen für die Map eingelesen. Es sind immer zwei: Sprite-Nummer für die Grafikdarstellung und der Kollisionswert der uns sagt ob das Tile begehbar ist oder nicht. Dazu gehen wir alle Spalten und Reihen der Map durch und speichern für jedes Tile zuerst die Sprite Nummer und dann eine 1, wenn Tile nicht begehbar ist oder eine 0 wenn begehbar. Getrennt werden diese beiden Informationen mit einem Komma und mit einem Hochstrich (Strg+Alt) abgeschlossen. Nach jeder gespeicherten Reihe von Tile gibt es einen Zeilenumbruch. Diese ganzen Daten werden in eine Textdatei gespeichert. Das hat den Vorteil, dass man eine Karte mit einfachem Texteditor erstellen und verändern kann.
Dazu gleich ein Beispiel:
Mapdatei
Wenn man diese Beispielkarte lädt, dann bekommt man Folgendes zu sehen.
Testmap
Die Laderoutine ergibt sich von selbst, wenn man das Format der Datei kennt. Ich denke, dass es nicht nötig ist zu erklären wie sie funktioniert.
// Lade-Methode bool Tilemap::Load(const char* path) { std::ifstream in; // Map-Datei öffnen in.open(path); // wenn datei geöffnet ist, dann lesen if(in.is_open()) { // ein buffer fürs einlesen char buffer[256]; // Höhe einlesen in.getline(buffer, 8, '\n'); m_map_height = atoi(buffer); // Breite einlesen in.getline(buffer, 8, '\n'); m_map_width = atoi(buffer); // Tile-Höhe einlesen in.getline(buffer, 8, '\n'); m_tile_height = atoi(buffer); // Tile-Breite einlesen in.getline(buffer, 8, '\n'); m_tile_width = atoi(buffer); // Anzahl der Sprites einlesen in.getline(buffer, 8, '\n'); unsigned int sprite_anzahl = atoi(buffer); // Alle Pfade zu den Sprites einlesen und die Sprites erstellen for(unsigned int i = 0; i < sprite_anzahl; i++) { // Pfad einlesen in.getline(buffer, 255, '\n'); // Sprite erstellen CSprite *sp = new CSprite; // hier müssen wir char[] in wchar_t[] umwandeln, // weil getline char[] verwendet und unsere // Create-Methode in CSprite wchar_t[] wchar_t pfad[255]; MultiByteToWideChar(CP_UTF8, 0, buffer, -1, pfad, 255); // Textur laden sp->Create(m_lpDevice, pfad); // Sprite in die Liste einfügen m_sprite.push_back(sp); } // Alle SpriteIDs und Collisionwerte einlesen // Dazu alle Spalten und alle Zeilen der Map durchlaufen for(unsigned int i = 0; i < m_map_height; i++) { // Neue Zeile erstellen std::vector< Tile > zeile; // Alle Werte für eine Zeile einlesen for(unsigned int j = 0; j < m_map_width; j++) { Tile tile; // erster Wert bedeutet spriteID in.getline(buffer, 8, ','); tile.GroundLayer = atoi(buffer); // zweiter Wert sagt aus ob das Tile begehbar ist in.getline(buffer, 8, '|'); tile.Collision = (atoi(buffer)==1) ? true : false; // Tile in die Zeile einfügen zeile.push_back(tile); } // Die Zeile in Vector einfügen m_map.push_back(zeile); } } else // Datei wurde nicht geöffnet return false; // Datei schließen in.close(); return true; }
Beispiel 6 herunterladen: Tilemap Beispiel 6
Jetzt wissen Sie wie man einfache 2D Karten auf den Bildschirm zaubert und können selbst ein kleines Spiel erschaffen. Bomberman, Pacman, Snake oder ähnliche Spiele lassen sich durch unsere Tilemap-Klasse ganz gut realisieren (natürlich fehlt dazu die eigentliche Logik des Spiels). Die absolute Krönung wäre natürlich ein kleines RPG im Stil von Zelda :)
Wie schon am Anfang des ersten Tutorials gesagt, wurden einige Konzepte des guten Programmierstils von mir gebrochen (Klassen by Value übergeben ect. ) und nicht alle Techniken richtig angewandt worden. Es gibt noch so viele Dinge die man implementieren könnte, was aber mehr oder weniger zu einem richtigen Spiel führen und damit den Rahmen dieses Turorials sprengen würde. Im Netz gibt es viele gute Artikel, die hier fehlende Informationen (beispielweise über Scrolling) gut erläutern.
Viel Spaß beim Programmieren :)
Quellen:
Titel:Spieleprogrammierung. Konzeption, Entwicklung, Programmierung, m. CD-ROM. Das bhv Taschenbuch
Autor: Lennart Steinke
Seiten: 767
ISBN: 3826680758
echt super tut, weiter so!
Genau das was ich gesucht habe… big thx
Hey, ich hab da mal eine Frage.
Sagen wir, ich möchte eine neue Map laden, wenn ich an einer bestimmten Position bin, wie mache ich das dann?
Ich habe das so gelöst, dass ich einfach eine neue Map laden, wenn der Spieler eine bestimmte Position erreicht.
In die Load-Methode habe ich dazu auch dies eingefügt:
if(!m_sprite.empty())
{
std::vector::iterator i;
for(i = m_sprite.begin(); i != m_sprite.end(); ++i)
{
delete (*i);
*i = NULL;
}
m_sprite.clear();
}
std::vector< std::vector >::iterator it;
if(!m_map.empty())
{
for(it = m_map.begin(); it != m_map.end(); ++it)
{
i->clear();
}
m_map.clear();
}
...
Das Problem ist nur, dass sich das Programm aufhängt und ich den Fehler
„terminate called after throwing instance of ’std::out_of_range‘
what(): vector::_M_range_check
Aborted“ bekomme.
Wär nett, wenn du mir helfen könntest, hab schon gegooglet und versucht, den Fehler zu finden, aber ich komm einfach nicht drauf.
Mfg Ressaw
PS.: Tolles Tutorial, haste echt gut gemacht.
Ein großes Lob dafür, konnte ich sehr gut gebrauchen :)
Hi.
Ja, so könnte man es machen. Wenn der Spieler eine vorgegebene Position erreicht, wird einfach eine neue Map-Datei geladen. Dann muss man nur der Spieler an die gewünschte Position setzen. Man kann die Map-Datei so erweitern, dass sie gleich x- und y-Position des Spielers enthält.
Zu deinem Code:
Der erste Abschnitt ist nicht korrekt. Schau mal in den Destrukter oben, da siehst du, dass da noch was fehlt.
Zum zweiten Teil: Da reicht nur m_map.clear();
Hallo, erstmal danke für dieses geniale Tutorial.
Endlich habe ich einen funktionierenden Ansatz für meine TileMap-Engine
Jedoch frage ich mich warum du einen Randomseed definiert hast, ist dieser von irgendeinem Nutzen?
Hallo,
Danke!
Ich habe den Code kurz überflogen und sehe auch keinen rand-Aufruf. Wahrscheinlich hatte ich beim Erstellen des Beispielcodes etwas getestet und später vergessen es rauszunehmen. ;)