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
Tworzymy komponent zwany
Każdy obiekt w grze jest reprezentowany jako
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
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
Klasa
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
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
Tutaj ładujemy obiekty gry z dwoma nowymi teksturami, a mianowicie cegła i niezniszczalna cegła.
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
Klasa
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
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
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
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:
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:
Obiekt wiosła będzie miał pozycję, rozmiar i teksturę sprite, więc sensowne jest zdefiniowanie wiosła jako
// 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
Po zainicjowaniu wiosła gracza, musimy również dodać poniższą linijkę do funkcji
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ę
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ą 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.
Poniżej znajdziesz zaktualizowany kod klasy Game: