Gra 2D, część 3: Wyświetlanie przewijanej mapy

18.01.2010 - Łukasz Milewski
TrudnośćTrudność

Wyświetlanie mapy

To nie koniec pracy. Zapewne chciałbyś już, Drogi Czytelniku, zobaczyć mapę (nie oszukujmy się - reprezentacja mapy w pamięci jest mało interesująca). W tym celu zaprogramujemy klasę - siatkę sprite'ów. (plik SpriteGrid.h):

Pokaż/ukryj kod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#ifndef __SPRITE_GRID_H__
#define __SPRITE_GRID_H__
 
#include <vector> 
#include "Sprite.h"
#include "Level.h"
 
class SpriteGrid {
public:
    explicit SpriteGrid();
 
    void SetLevel(const LevelPtr lvl, double dx);
    void Draw(double dx) const;
 
    void StoreSprite(FT::FieldType ft, SpritePtr p);
 
private:
    void SetSprite(size_t x, size_t y, SpritePtr sprite) { 
        m_grid.at(y).at(x) = sprite; 
    }
 
private:
    std::vector< std::vector< SpritePtr > > m_grid;
    std::vector<SpritePtr> m_sprites;
};
 
#endif
  

Jak widać, mamy metody do ustawiania pola siatki na określony sprite (SetSprite), do ustawiania poziomu wraz z przesunięciem (SetLevel), do rysowania mapy (Draw) oraz do zapamiętywania sprite'ów (czyli kojarzenia sprite'a z typem pola) (StoreSprite).

Pole m_grid reprezentuje naszą wirtualną siatkę (tę z pierwszego rysunku). W tablicy m_sprites w polu m_sprites[t] jest sprite reprezentujący typ pola t.

Jako uważny Czytelnik, na pewno zastanawiasz się właśnie, czym jest przesunięcie. Gracz podczas gry będzie się poruszał po mapie, zatem będzie widział zawsze pewien fragment poziomu. Klasa SpriteGrid musi wiedzieć, który fragment poziomu narysować. Możemy przekazać tę informację klasie np. poprzez parametr określający, jak bardzo gracz jest przesunięty względem poziomu (albo poziom względem gracza).

Już za chwilę będziemy mogli ujrzeć mapę na ekranie monitora. Najpierw jednak musimy dodać do klasy Renderer dwie metody, które pomogą nam w implementacji metod klasy SpriteGrid (plik Renderer.h):

1
2
3
4
5
6
    size_t GetHorizontalTilesOnScreenCount() const { 
        return 1.0 / m_tile_width  + 0.5; 
    }
    size_t GetVerticalTilesOnScreenCount() const { 
        return 1.0 / m_tile_height + 0.5; 
    }

Mamy już wszystkie potrzebne informacje, aby zdefiniować SpriteGrid (plik SpriteGrid.cpp):

1
2
#include "SpriteGrid.h"
#include "Engine.h"

Konstruktor musi jedynie pamiętać, aby stworzyć siatkę o szerokości o jedno pole większej niż liczba pełnych widocznych pól na ekranie. Zauważmy, że gdy gracz poruszy się o połowę kafla w prawą stronę, widać połowę następnego kafla z prawej strony. Ze względu na właśnie ten przypadek musimy znać następny kafel, zanim będzie on całkowicie widoczny.

1
2
3
4
5
6
7
8
9
SpriteGrid::SpriteGrid() {
    psize_t height = Engine::Get().Renderer()->GetVerticalTilesOnScreenCount();
    size_t width  = Engine::Get().Renderer()->GetHorizontalTilesOnScreenCount();
    width++; /
    m_grid.resize(height);
    for (size_t i = 0; i < height; ++i) {
        m_grid.at(i).resize(width); 
    }
}

SetLevel zapisuje w komórkach siatki sprite'y. Są one dobrane na podstawie odpowiednio przesuniętego poziomu. Ta metoda iteruje po wszystkich polach siatki.

1
2
3
4
5
6
void SpriteGrid::SetLevel(const LevelPtr lvl, double dx) {
    int half_grid_width = (m_grid.at(0).size()-1) / 2;
 
    for (size_t y = 0; y < m_grid.size(); ++y) {
        std::vector<SpritePtr>& row = m_grid.at(y);
        for (size_t x = 0; x < row.size(); ++x) {

Współrzędnej y w siatce odpowiada współrzędna y poziomu gry (mapa nigdy nie przesuwa się w pionie). Współrzędnej x odpowiada x przesunięte o długość połowy ekranu (pozycja gracza na ekranie) do tyłu i o pozycję gracza do przodu (gracz przesuwa się o jeden kafel, gdy poziom na ekranie przesuwa się o jeden kafel).

1
2
     int draw_x = x + static_cast<int>(dx) - half_grid_width + 1;
     int draw_y = y; 

Teraz możemy pobrać typ pola z wcześniej napisanej klasy Level. Na tej podstawie wystarczy wybrać odpowiedni sprite.

1
2
3
4
5
6
7
8
9
10
            const FT::FieldType& ft = lvl->Field(draw_x, draw_y);
            if (ft != FT::None) {
                SetSprite(x, y, m_sprites.at(ft));
            }
            else {
                SetSprite(x, y, SpritePtr());
            }
        }
    }
}

Znamy przesunięcie, czyli pozycję kafli w przestrzeni świata (dx). Jednak wyświetlając siatkę, potrzebujemy jej pozycji w przestrzeni oka (ang. eye space - to, co widzisz na ekranie). Gdyby kafle miały wymiary 1x1, to obie przestrzenie byłyby identyczne. Tak nie jest, dlatego potrzebujemy przekształcić pozycję kafli w świecie na pozycję kafli w przestrzeni oka. Kafel ma szerokość tile_width (a nie 1), czyli jest przeskalowany. Zatem naszym przekształceniem będzie po prostu tile_width*pozycja. Dodatkowo, przesuniemy kafle o 0.45, gdyż chcemy, aby gracz był w środku ekranu.

Gdybyśmy w tym momencie zakończyli obsługę mapy, przesuwałaby się ona skokowo - tzn. zawsze podczas przejścia na następny kafel, posuwałaby się o 1. Nas oczywiście interesuje płynne przejście. Aby uzyskać taki efekt, wystarczy policzyć część ułamkową dx (bo przy powyższej procedurze jest ona ignorowana) i przesunąć mapę na ekranie w lewo (przy pomocy glTranslatef) o tę część ułamkową. Dzięki temu gracz będzie miał wrażenie, że mapa przesuwa się w sposób ciągły.

Pozostała część kodu nie powinna sprawiać nam problemu - po prostu iterujemy po wszystkich kaflach i rysujemy je (wywołujemy metodę DrawCurrentFrame, omówiliśmy ją w poprzednim artykule).

Pokaż/ukryj kod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void SpriteGrid::Draw(double dx) const {
    const double tile_width  = Engine::Get().Renderer()->GetTileWidth();
    const double tile_height = Engine::Get().Renderer()->GetTileHeight();
 
    glPushMatrix();
    {
        glTranslatef(dx*tile_width-0.45, 0, 0);
 
        double offset = dx - static_cast<int>(dx);
        glTranslatef(-offset * tile_width, 0, 0);
        for (size_t y = 0; y < m_grid.size(); ++y) {
            const std::vector<SpritePtr>& row = m_grid.at(y);
            for (size_t x = 0; x < row.size(); ++x) {
                const SpritePtr& sprite = row.at(x);
                if (sprite) {
                    sprite->DrawCurrentFrame(x * tile_width, 
                                             1.0 - (y+1) * tile_height, 
                                             tile_width, tile_height);
                }
            }
        }
    }
    glPopMatrix();
}
  

Zrozumienie działania metody StoreSprite także nie powinno być dla nas kłopotliwe.

1
2
3
4
5
void SpriteGrid::StoreSprite(FT::FieldType ft, SpritePtr sp) {
    if (m_sprites.size() <= static_cast<size_t>(ft)) 
        m_sprites.resize(ft + 1);
    m_sprites.at(ft) = sp;
}

Wspaniale!!! Napisaliśmy już cały potrzebny kod, aby wyświetlić mapę.

Integrujemy SpriteGrid z grą

Do klasy App dodajemy pola (plik App.h):
1
2
    LevelPtr m_level;
    SpriteGrid m_level_view;

Następnie w metodzie App::Draw, tuż przed rysowaniem gracza, dodajemy te dwie linijki, które wyświetlą siatkę na podstawie poziomu i pozycji gracza:

1
2
    m_level_view.SetLevel(m_level, m_player->GetX());
    m_level_view.Draw(m_player->GetX());

Nie zapominamy także o nowych plikach nagłówkowych w App.h:

1
2
#include "Level.h"
#include "SpriteGrid.h" 

Powinniśmy również zmienić konstruktor App::App, aby wyglądał następująco (plik App.h):

Pokaż/ukryj kod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    explicit App(size_t win_width, size_t win_height, bool fullscreen_mode) :
        m_window_width(win_width),
        m_window_height(win_height),
        m_fullscreen(fullscreen_mode) {
 
        m_level_view.StoreSprite(FT::PlatformLeftEnd,  
            SpritePtr(new Sprite(
                Engine::Get().SpriteConfig()->Get("platform_left"))));
        m_level_view.StoreSprite(FT::PlatformMidPart,  
            SpritePtr(new Sprite(
                Engine::Get().SpriteConfig()->Get("platform_mid"))));
        m_level_view.StoreSprite(FT::PlatformRightEnd, 
            SpritePtr(new Sprite(
                Engine::Get().SpriteConfig()->Get("platform_right"))));
 
        m_level.reset(new Level());
        m_level->LoadFromFile("data/1.lvl");
        m_player.reset(new Player(9, 5, m_level->GetWidth()));
    }
  

Jak widzisz, wykorzystujemy tu metodę StoreSprite, aby powiązać sprite'y z odpowiednimi typami platformy. Następnie wczytujemy poziom z pliku. Zwróćmy uwagę, że dane są wczytywane na podstawie konfiguracji sprite'ów. Dlatego musimy ją jeszcze odpowiednio uzupełnić. Nowa metoda SpriteConfig::SpriteConfig wygląda teraz tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SpriteConfig::SpriteConfig() {
    Insert("player_right", 
           SpriteConfigData(DL::Player, 5, 0.2, 0, 4 * 32, 32, 32, true));
    Insert("player_left",  
           SpriteConfigData(DL::Player, 5, 0.2, 0, 5 * 32, 32, 32, true));
    Insert("player_stop",  
           SpriteConfigData(DL::Player, 1, 0.2, 0, 6 * 32, 32, 32, true));
 
    Insert("platform_left",  
           SpriteConfigData(DL::Foreground, 1, 1, 0, 1*32, 32, 32, true));
    Insert("platform_mid",   
           SpriteConfigData(DL::Foreground, 1, 1, 0, 2*32, 32, 32, true));
    Insert("platform_right", 
           SpriteConfigData(DL::Foreground, 1, 1, 0, 3*32, 32, 32, true));
}

Ustawiliśmy wielkość widocznej mapy na 20x20. Zatem szerokość i wysokość kafla to 0.05 (wówczas na mapie mieści się 20x20 kafli). Dlatego w pliku Renderer.h zmieniamy konstruktor na taki:

1
2
3
    Renderer() :
        m_tile_width(0.05), m_tile_height(0.05) {
    }

Zwróćmy jeszcze uwagę, że SpriteGrid jest tworzony w konstruktorze App::App. SpriteGrid wymaga wówczas, aby renderer był w pełni zainicjalizowany. Dlatego do main.cpp musimy dodać ładowanie silnika. Obecnie main.cpp powinien wyglądać tak:

Pokaż/ukryj kod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include "App.h"
#include "Engine.h"
 
int main(int argc, char *argv[]) {
    std::cout << "      strzałki lewo/prawo  -  poruszanie się postacią\n"
              << "         strzałka do góry  -  skok\n"
              << " przytrzymanie klawisza d  -  bieganie"
              << std::endl;
    
    Engine::Get().Load(); /// *NOWE*
 
    App app(600, 400, false);
    app.Run();
    return 0;
}
  

Teraz możemy już skompilować i uruchomić program. Zobaczysz poziom. Możesz się po nim poruszać przy pomocy strzałek. Gracz zawsze jest w środku. Nie ma jeszcze kolizji (zajmiemy się tym później), dlatego niemożliwe jest wskakiwanie na platformy.

Pierwsza obiecana modyfikacja

OK. Zostały nam jeszcze dwie kwestie do rozwiązania na dzisiaj. Najpierw sprawmy, aby gracz nie mógł cofnąć się do miejsca, które stracił z widoku. Spróbuj sam zaimplementować taką funkcjonalność!

Na początek dodamy nowe pole do klasy gracza. Jako ostatnie pole dopisz:

1
double m_max_x_pos;

Będzie ono pamiętało maksymalną pozycję, na jakiej był gracz. Gracz nigdy nie może cofnąć się dalej niż na pozycję m_max_x_pos - połowa szerokości ekranu w kaflach. Wynika to z faktu, że gracz widzi połowę ekranu w kaflach wstecz, zatem będąc na pozycji m_max_x_pos nie mógł widzieć tego, co jest przed pozycją m_max_x_pos - połowa szerokości ekranu w kaflach. Oznacza to, że nie może już tam wrócić. Po dodaniu pola pozostaje nam jego ustawienie w konstruktorze Player::Player (plik Player.cpp). Linijki:

1
2
3
4
      m_can_go_left(true),
      m_can_go_right(true) {
    SetDefaultMoving();
}

zamieniamy na:

1
2
3
4
      m_can_go_right(true),
      m_max_x_pos(x) { // *NOWE*
    SetDefaultMoving();
}

Musimy jeszcze zmienić metodę Player::Update (plik Player.cpp). Odpowiedni kod dodamy za linijkami:

1
2
3
4
5
6
    // nie można wyjść poza mapę
    if (m_x < 0) {
        m_x = 0; // nie można wyjść za początek mapy
    } else if (m_x > m_level_width - 1) {
        m_x = m_level_width - 1; // nie można wyjść za ostatni kafel mapy
    }

tak, aby ostatecznie wyglądało to w ten sposób:

1
2
3
4
5
6
7
8
9
    // nie możemy cofnąć się do miejsc, których już nie widzimy
    if (m_x > m_max_x_pos) {
        m_max_x_pos = m_x;
    }
    const size_t half_screen_tiles_count = 
        (Engine::Get().Renderer()->GetHorizontalTilesOnScreenCount()-1)/2;
    if (m_x < m_max_x_pos - half_screen_tiles_count) {
        m_x = m_max_x_pos - half_screen_tiles_count;
    } 

Działanie jest bardzo proste. Na początek uaktualniamy m_max_x_pos (pierwszy if). Następnie sprawdzamy, czy aktualna pozycja nie jest wcześniejsza niż pierwsza dozwolona. Jeżeli tak, to nie pozwalamy na ruch.

5
Twoja ocena: Brak Ocena: 5 (4 ocen)

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com