This is the Polish translation of In-Practice/2D-Game/Levels article of learnopengl.com tutorial series.

Breakout zawiera kompletne poziomy z wieloma kolorowymi klockami. Chcemy, aby te poziomy były elastyczne, aby mogły obsługiwać dowolną liczbę wierszy i/lub kolumn, chcemy, aby poziomy miały cegły, które nie mogą zostać zniszczone. Chcemy, aby poziomy obsługiwały wiele ceglanych kolorów i chcemy, aby poziomy były przechowywane w plikach (tekstowych).

W tym samouczku krótko przejdziemy przez kod poziomu gry, który jest używany do zarządzania dużą ilością klocków. Najpierw musimy zdefiniować, czym jest cegła/klocek.

Tworzymy komponent zwany obiektem gry (ang. game object), który działa jako podstawowa reprezentacja obiektu wewnątrz gry. Taki obiekt gry przechowuje dane stanu, takie jak jego pozycja, rozmiar i prędkość. Zawiera kolor, składnik rotacji, niezależnie od tego, czy jest on niezniszczalny, czy zniszczony, a także przechowuje zmienną typu Texture2D jako sprite.

Każdy obiekt w grze jest reprezentowany jako GameObject lub pochodna tej klasy. Poniżej znajdziesz kod klasy GameObject:

Poziom w Breakout w zasadzie składa się wyłącznie z cegieł, więc możemy reprezentować poziom przez zbiór cegieł. Ponieważ cegła wymaga niemal takiego samego stanu jak obiekt gry, będziemy reprezentować każdą cegłę poziomu jako GameObject. Układ klasy GameLevel wygląda następująco:

    class GameLevel
    {
    public:
        std::vector<GameObject> Bricks;

        GameLevel() { }
        // Loads level from file
        void Load(const GLchar *file, GLuint levelWidth, GLuint levelHeight);
        // Render level
        void Draw(SpriteRenderer &renderer);
        // Check if the level is completed (all non-solid tiles are destroyed)
        GLboolean IsCompleted();
    private:
        // Initialize level from tile data
        void init(std::vector<std::vector<GLuint>> tileData, GLuint levelWidth, GLuint levelHeight);
    };  

Ponieważ poziom jest ładowany z pliku zewnętrznego (tekstowego), musimy zaproponować pewien rodzaj struktury poziomu. Poniżej znajduje się przykład tego, jak poziom gry może wyglądać w pliku tekstowym:

    1 1 1 1 1 1 
    2 2 0 0 2 2
    3 3 4 4 3 3

Poziom jest przechowywany w strukturze podobnej do macierzy, gdzie każda liczba reprezentuje rodzaj cegły, z których każda jest oddzielona spacją. W ramach kodu poziomu możemy przypisać to, co reprezentuje każda liczba. Wybraliśmy następującą reprezentację:

  • Liczba 0: brak klocka, pusta przestrzeń wewnątrz poziomu.
  • Liczba 1: cegła, której nie można zniszczyć.
  • Liczba wyższa niż 1: zniszczalna cegła; każda cyfra różni się tylko kolorem.

Powyższy przykładowy poziom, po przetworzeniu przez GameLevel, wygląda następująco:

Przykład poziomu przy użyciu klasy GameLevel

Klasa GameLevel używa dwóch funkcji do generowania poziomu z pliku. Najpierw ładuje wszystkie liczby do dwuwymiarowego wektora w ramach funkcji Load, która następnie przetwarza te liczby (aby utworzyć wszystkie obiekty gry) w swojej funkcji init.

    void GameLevel::Load(const GLchar *file, GLuint levelWidth, GLuint levelHeight)
    {
        // Clear old data
        this->Bricks.clear();
        // Load from file
        GLuint tileCode;
        GameLevel level;
        std::string line;
        std::ifstream fstream(file);
        std::vector<std::vector<GLuint>> tileData;
        if (fstream)
        {
            while (std::getline(fstream, line)) // Read each line from level file
            {
                std::istringstream sstream(line);
                std::vector<GLuint> row;
                while (sstream >> tileCode) // Read each word seperated by spaces
                    row.push_back(tileCode);
                tileData.push_back(row);
            }
            if (tileData.size() > 0)
                this->init(tileData, levelWidth, levelHeight);
        }
    } 

Załadowane tileData jest następnie przekazywane do funkcji init klasy GameLevel:

    void GameLevel::init(std::vector<std::vector<GLuint>> tileData, GLuint lvlWidth, GLuint lvlHeight)
    {
        // Calculate dimensions
        GLuint height = tileData.size();
        GLuint width = tileData[0].size();
        GLfloat unit_width = lvlWidth / static_cast<GLfloat>(width);
        GLfloat unit_height = lvlHeight / height;
        // Initialize level tiles based on tileData		
        for (GLuint y = 0; y < height; ++y)
        {
            for (GLuint x = 0; x < width; ++x)
            {
                // Check block type from level data (2D level array)
                if (tileData[y][x] == 1) // Solid
                {
                    glm::vec2 pos(unit_width * x, unit_height * y);
                    glm::vec2 size(unit_width, unit_height);
                    GameObject obj(pos, size, 
                        ResourceManager::GetTexture("block_solid"), 
                        glm::vec3(0.8f, 0.8f, 0.7f)
                    );
                    obj.IsSolid = GL_TRUE;
                    this->Bricks.push_back(obj);
                }
                else if (tileData[y][x] > 1)	
                {
                    glm::vec3 color = glm::vec3(1.0f); // original: white
                    if (tileData[y][x] == 2)
                        color = glm::vec3(0.2f, 0.6f, 1.0f);
                    else if (tileData[y][x] == 3)
                        color = glm::vec3(0.0f, 0.7f, 0.0f);
                    else if (tileData[y][x] == 4)
                        color = glm::vec3(0.8f, 0.8f, 0.4f);
                    else if (tileData[y][x] == 5)
                        color = glm::vec3(1.0f, 0.5f, 0.0f);

                    glm::vec2 pos(unit_width * x, unit_height * y);
                    glm::vec2 size(unit_width, unit_height);
                    this->Bricks.push_back(
                        GameObject(pos, size, ResourceManager::GetTexture("block"), color)
                    );
                }
            }
        }  
    }

Funkcja init iteruje po każdej z załadowanych liczb i dodaje GameObject do wektora Bricks na podstawie przetworzonej liczby. Rozmiar każdej cegły jest obliczany automatycznie (unit_width i unit_height) w oparciu o całkowitą liczbę cegieł, tak aby każda cegła idealnie pasowała do granic ekranu.

Tutaj ładujemy obiekty gry z dwoma nowymi teksturami, a mianowicie cegła i niezniszczalna cegła.

Obraz dwóch rodzajów tekstur bloków

Miłą sztuczką jest to, że te tekstury są całkowicie w skali szarości. Efekt jest taki, że możemy starannie manipulować ich kolorami w kodzie gry poprzez pomnożenie ich odcieni szarości za pomocą zdefiniowanego wektora koloru; dokładnie tak samo jak w SpriteRenderer. W ten sposób dostosowanie wyglądu ich kolorów nie wygląda na zbyt dziwne.

Klasa GameLevel zawiera także kilka innych funkcji, takich jak renderowanie wszystkich nie zniszczonych klocków lub sprawdzanie, czy wszystkie cegły zostały zniszczone. Poniżej znajdziesz kod źródłowy klasy GameLevel:

Klasa poziomu gry zapewnia nam dużą elastyczność, ponieważ obsługiwana jest dowolna liczba wierszy i kolumn, a użytkownik może łatwo tworzyć własne poziomy, modyfikując pliki poziomów.

W grze

Chcielibyśmy wspierać wiele poziomów w grze Breakout, więc będziemy musieli nieco rozszerzyć klasę gry, dodając wektor zawierający zmienne typu GameLevel. Będziemy również przechowywać aktualnie aktywny poziom, gdy będziemy aktywny:

    class Game
    {
        [...]
        std::vector<GameLevel> Levels;
        GLuint                 Level;
        [...]  
    };

Ta samouczkowa wersja gry Breakout ma w sumie 4 poziomy:

Każda z tekstur i poziomów jest następnie inicjowana w ramach funkcji Init klasy gry:

    void Game::Init()
    {
        [...]
        // Load textures
        ResourceManager::LoadTexture("textures/background.jpg", GL_FALSE, "background");
        ResourceManager::LoadTexture("textures/awesomeface.png", GL_TRUE, "face");
        ResourceManager::LoadTexture("textures/block.png", GL_FALSE, "block");
        ResourceManager::LoadTexture("textures/block_solid.png", GL_FALSE, "block_solid");
        // Load levels
        GameLevel one; one.Load("levels/one.lvl", this->Width, this->Height * 0.5);
        GameLevel two; two.Load("levels/two.lvl", this->Width, this->Height * 0.5);
        GameLevel three; three.Load("levels/three.lvl", this->Width, this->Height * 0.5);
        GameLevel four; four.Load("levels/four.lvl", this->Width, this->Height * 0.5);
        this->Levels.push_back(one);
        this->Levels.push_back(two);
        this->Levels.push_back(three);
        this->Levels.push_back(four);
        this->Level = 1;
    }  

Teraz wszystko, co pozostało do zrobienia, to renderowanie poziomu, co osiągamy poprzez wywołanie funkcji Draw aktualnie aktywnego poziomu, która z kolei wywołuje każdą funkcję Draw każdego GameObject wykorzystując dany mechanizm renderowania sprite’a. Oprócz poziomu, będziemy również renderować scenę z ładnym obrazem tła (dzięki uprzejmości Tenha):

    void Game::Render()
    {
        if(this->State == GAME_ACTIVE)
        {
            // Draw background
            Renderer->DrawSprite(ResourceManager::GetTexture("background"), 
                glm::vec2(0, 0), glm::vec2(this->Width, this->Height), 0.0f
            );
            // Draw level
            this->Levels[this->Level].Draw(*Renderer);
        }
    }

Rezultatem jest ładnie wyrenderowany poziom, który naprawdę zaczyna sprawiać, że gra staje się bardziej żywa:

Poziom w breakout OpenGL

Wiosło gracza

Podczas gdy omawiamy temat poziomów, możemy równie dobrze wprowadzić wiosło na dole sceny, które jest kontrolowane przez gracza. Wiosło pozwala tylko na ruch poziomy, a ilekroć dotknie krawędzi sceny, jego ruch powinien się zatrzymać. Dla wiosła gracza użyjemy tej tekstury:

Obraz tekstury wiosła w breakout OpenGL

Obiekt wiosła będzie miał pozycję, rozmiar i teksturę sprite, więc sensowne jest zdefiniowanie wiosła jako GameObject.

    // Initial size of the player paddle
    const glm::vec2 PLAYER_SIZE(100, 20);
    // Initial velocity of the player paddle
    const GLfloat PLAYER_VELOCITY(500.0f);

    GameObject      *Player;

    void Game::Init()
    {
        [...]    
        ResourceManager::LoadTexture("textures/paddle.png", true, "paddle");
        [...]
        glm::vec2 playerPos = glm::vec2(
            this->Width / 2 - PLAYER_SIZE.x / 2, 
            this->Height - PLAYER_SIZE.y
        );
        Player = new GameObject(playerPos, PLAYER_SIZE, ResourceManager::GetTexture("paddle"));
    }

Tutaj zdefiniowaliśmy kilka stałych wartości, które określają rozmiar i prędkość wiosła. W funkcji Init klasy Game obliczamy początkową pozycję wiosła w scenie. Upewniamy się, że środek wiosła gracza jest wyrównany do poziomego środka sceny.

Po zainicjowaniu wiosła gracza, musimy również dodać poniższą linijkę do funkcji Render klasy Game:

    Player->Draw(*Renderer);  

Jeśli teraz uruchomisz grę, zobaczysz nie tylko poziom, ale także wiosło gracza wyrównane do dolnej krawędzi sceny. Jak na razie nie robi ono nic, więc zagłębimy się również w funkcję ProcessInput, aby poziomo przesuwać wiosło za każdym razem, gdy użytkownik nacisnął przycisk A lub D.

    void Game::ProcessInput(GLfloat dt)
    {
        if (this->State == GAME_ACTIVE)
        {
            GLfloat velocity = PLAYER_VELOCITY * dt;
            // Move playerboard
            if (this->Keys[GLFW_KEY_A])
            {
                if (Player->Position.x >= 0)
                    Player->Position.x -= velocity;
            }
            if (this->Keys[GLFW_KEY_D])
            {
                if (Player->Position.x <= this->Width - Player->Size.x)
                    Player->Position.x += velocity;
            }
        }
    } 

Tutaj poruszamy wiosłem gracza w lewo lub prawo na podstawie tego, który klawisz użytkownik nacisnął (zauważ, jak mnożymy prędkość ze zmienną deltatime). Jeśli wartość x wiosła byłaby mniejsza niż 0, przesunęłaby się poza lewą krawędź, więc przesuwamy wiosło tylko w lewo, jeśli wartość x wiosła jest większa niż pozycja x lewej krawędzi (0.0). Robimy to samo, gdy wiosło chce się przebić przez prawą krawędź, ale musimy porównać pozycję prawej krawędzi z prawą krawędzią wiosła (odejmij szerokość wiosła od pozycji x prawej krawędzi).

Teraz możemy przesuwać wiosłem w poprzek dolnej krawędzi.

Obraz breakout OpenGL teraz z wiosłem gracza

Poniżej znajdziesz zaktualizowany kod klasy Game: