Tilemap 2

Inhalt

Einleitung
Map Klasse
Kachelnbasierende Kollisionserkennung
Maps laden
Fazit

Einleitung

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.

Map Klasse

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:

grafik: Testmap
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:

  1. Bestimme die gewünschte Position.
  2. Schaue ob die angestrebte Position nicht außerhalb der Kartengrenzen liegt.
  3. Schaue ob die angestrebte Position begehbar ist.
  4. 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 ;)

Maps laden

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:

grafik: Mapdatei
Mapdatei

Wenn man diese Beispielkarte lädt, dann bekommt man Folgendes zu sehen.

grafik: Testmap
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

Fazit

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:
cover
Titel:Spieleprogrammierung. Konzeption, Entwicklung, Programmierung, m. CD-ROM. Das bhv Taschenbuch
Autor: Lennart Steinke
Seiten: 767
ISBN: 3826680758

7 Gedanken zu “Tilemap 2”

  1. 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

  2. PS.: Tolles Tutorial, haste echt gut gemacht.
    Ein großes Lob dafür, konnte ich sehr gut gebrauchen :)

  3. 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();

  4. 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?

  5. 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. ;)

Schreibe einen Kommentar