На этом уроке мы добавим в нашу игру «Breakout» последние улучшения: игровые жизни, условие выигрыша и обратную связь в виде обработанного текста. Данный материал в значительной степени опирается на ранее опубликованный урок о рендеринге текста, поэтому настоятельно рекомендуется сначала прочитать его (если вы еще этого не сделали).
Рендеринг текста
В игре «Breakout» весь код рендеринга текста инкапсулируется в класс TextRenderer, который содержит инициализацию библиотеки FreeType, конфигурацию рендера и непосредственно сам код рендера.
#Класс TextRenderer
Заголовочный файл — text_renderer.h:
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
#ifndef TEXT_RENDERER_H #define TEXT_RENDERER_H #include <map> #include <glad/glad.h> #include <glm/glm.hpp> #include "texture.h" #include "shader.h" // Информация о состоянии символа, загруженному с помощью библиотеки FreeType struct Character { unsigned int TextureID; // ID текстуры глифа glm::ivec2 Size; // размер глифа glm::ivec2 Bearing; // смещение от линии шрифта до верхнего левого угла глифа unsigned int Advance; // горизонтальное смещение для перехода к следующему глифу }; // Класс TextRenderer предназначен для рендеринга текста, отображаемого шрифтом, // загруженным с помощью библиотеки FreeType. Загруженный шрифт обрабатывается и // сохраняется для последующего рендеринга в виде списка символов class TextRenderer { public: // Список предварительно скомпилированных символов std::map<char, Character> Characters; // Шейдер, используемый для рендеринга текста Shader TextShader; // Конструктор TextRenderer(unsigned int width, unsigned int height); // Список предварительно скомпилированных символов из заданного шрифта void Load(std::string font, unsigned int fontSize); // Рендеринг строки текста с использованием предварительно скомпилированного списка символов void RenderText(std::string text, float x, float y, float scale, glm::vec3 color = glm::vec3(1.0f)); private: // Состояние рендеринга unsigned int VAO, VBO; }; #endif |
Файл реализации — text_renderer.cpp:
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
#include <iostream> #include <glm/gtc/matrix_transform.hpp> #include <ft2build.h> #include FT_FREETYPE_H #include "text_renderer.h" #include "resource_manager.h" TextRenderer::TextRenderer(unsigned int width, unsigned int height) { // Загрузка и настройка шейдера this->TextShader = ResourceManager::LoadShader("../shaders/text_2d.vs", "../shaders/text_2d.frag", nullptr, "text"); this->TextShader.SetMatrix4("projection", glm::ortho(0.0f, static_cast<float>(width), static_cast<float>(height), 0.0f), true); this->TextShader.SetInteger("text", 0); // Загрузка VAO/VBO для текстурных прямоугольников glGenVertexArrays(1, &this->VAO); glGenBuffers(1, &this->VBO); glBindVertexArray(this->VAO); glBindBuffer(GL_ARRAY_BUFFER, this->VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(float) * 6 * 4, NULL, GL_DYNAMIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(float), 0); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindVertexArray(0); } void TextRenderer::Load(std::string font, unsigned int fontSize) { // Сначала очищаем ранее загруженные символы this->Characters.clear(); // Затем инициализируем и загружаем библиотеку FreeType FT_Library ft; if (FT_Init_FreeType(&ft)) // все функции в случае ошибки возвращают значение, отличное от 0 std::cout << "ERROR::FREETYPE: Could not init FreeType Library" << std::endl; // Загрузка шрифта в качестве face FT_Face face; if (FT_New_Face(ft, font.c_str(), 0, &face)) std::cout << "ERROR::FREETYPE: Failed to load font" << std::endl; // Устанавливаем размер загруженных глифов FT_Set_Pixel_Sizes(face, 0, fontSize); // Отключаем ограничение на выравнивание байтов glPixelStorei(GL_UNPACK_ALIGNMENT, 1); // Предварительно загружаем/компилируем символы шрифта и сохраняем их for (GLubyte c = 0; c < 255; c++) { // Загрузка символа глифа if (FT_Load_Char(face, c, FT_LOAD_RENDER)) { std::cout << "ERROR::FREETYTPE: Failed to load Glyph" << std::endl; continue; } // Генерация текстуры unsigned int texture; glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); glTexImage2D( GL_TEXTURE_2D, 0, GL_RED, face->glyph->bitmap.width, face->glyph->bitmap.rows, 0, GL_RED, GL_UNSIGNED_BYTE, face->glyph->bitmap.buffer ); // Установка параметров текстур glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // Теперь сохраняем символы для их дальнейшего использования Character character = { texture, glm::ivec2(face->glyph->bitmap.width, face->glyph->bitmap.rows), glm::ivec2(face->glyph->bitmap_left, face->glyph->bitmap_top), face->glyph->advance.x }; Characters.insert(std::pair<char, Character>(c, character)); } glBindTexture(GL_TEXTURE_2D, 0); // Когда закончили, освобождаем ресурсы FreeType FT_Done_Face(face); FT_Done_FreeType(ft); } void TextRenderer::RenderText(std::string text, float x, float y, float scale, glm::vec3 color) { // Активируем соответствующее состояние рендера this->TextShader.Use(); this->TextShader.SetVector3f("textColor", color); glActiveTexture(GL_TEXTURE0); glBindVertexArray(this->VAO); // Цикл по всем символам std::string::const_iterator c; for (c = text.begin(); c != text.end(); c++) { Character ch = Characters[*c]; float xpos = x + ch.Bearing.x * scale; float ypos = y + (this->Characters['H'].Bearing.y - ch.Bearing.y) * scale; float w = ch.Size.x * scale; float h = ch.Size.y * scale; // Обновляем VBO для каждого символа float vertices[6][4] = { { xpos, ypos + h, 0.0f, 1.0f }, { xpos + w, ypos, 1.0f, 0.0f }, { xpos, ypos, 0.0f, 0.0f }, { xpos, ypos + h, 0.0f, 1.0f }, { xpos + w, ypos + h, 1.0f, 1.0f }, { xpos + w, ypos, 1.0f, 0.0f } }; // Рендерим на прямоугольник текстуру глифа glBindTexture(GL_TEXTURE_2D, ch.TextureID); // Обновляем содержимое памяти VBO glBindBuffer(GL_ARRAY_BUFFER, this->VBO); glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vertices), vertices); // обязательно используйте функцию glBufferSubData(), а не функцию glBufferData() glBindBuffer(GL_ARRAY_BUFFER, 0); // Рендерим прямоугольник glDrawArrays(GL_TRIANGLES, 0, 6); // Теперь смещаем курсор к следующему глифу x += (ch.Advance >> 6) * scale; // битовый сдвиг на 6, чтобы получить значение в пикселях (2^6 = 64) } glBindVertexArray(0); glBindTexture(GL_TEXTURE_2D, 0); } |
Вершинный шейдер — файл text_2d.vs:
1 2 3 4 5 6 7 8 9 10 11 |
#version 330 core layout (location = 0) in vec4 vertex; out vec2 TexCoords; uniform mat4 projection; void main() { gl_Position = projection * vec4(vertex.xy, 0.0, 1.0); TexCoords = vertex.zw; } |
Фрагментный шейдер — файл text_2d.frag:
1 2 3 4 5 6 7 8 9 10 11 12 |
#version 330 core in vec2 TexCoords; out vec4 color; uniform sampler2D text; uniform vec3 textColor; void main() { vec4 sampled = vec4(1.0, 1.0, 1.0, texture(text, TexCoords).r); color = vec4(textColor, 1.0) * sampled; } |
Содержимое функций рендера текста почти в точности совпадают с аналогичным кодом из урока о рендеринге текста. Однако код рендеринга глифов немного отличается:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
void TextRenderer::RenderText(std::string text, float x, float y, float scale, glm::vec3 color) { [...] for (c = text.begin(); c != text.end(); c++) { float xpos = x + ch.Bearing.x * scale; float ypos = y + (this->Characters['H'].Bearing.y - ch.Bearing.y) * scale; float w = ch.Size.x * scale; float h = ch.Size.y * scale; // Обновляем VBO для каждого символа float vertices[6][4] = { { xpos, ypos + h, 0.0f, 1.0f }, { xpos + w, ypos, 1.0f, 0.0f }, { xpos, ypos, 0.0f, 0.0f }, { xpos, ypos + h, 0.0f, 1.0f }, { xpos + w, ypos + h, 1.0f, 1.0f }, { xpos + w, ypos, 1.0f, 0.0f } }; [...] } } |
Причина этих отличий заключается в том, что мы используем иную матрицу орфографической проекции. В игре «Breakout» все значения y
отсчитываются сверху вниз (при этом координата y
со значением 0.0f
соответствует верхнему краю экрана). Это означает, что мы должны немного изменить способ вычисления вертикального смещения.
Поскольку теперь мы визуализируем по нисходящим значениям параметра y
функции RenderText(), то представление вертикального смещения будет соответствовать расстоянию, на которое глиф перемещается вниз от верхней границы занимаемой им области. На следующей картинке это расстояние обозначено красной стрелочкой:
Чтобы вычислить данное вертикальное смещение, нам нужно получить высоту отведенной под глифы области (длину черной вертикальной стрелки от начала координат). К сожалению, библиотека FreeType не имеет для нас такой метрики. Но мы можем посмотреть в сторону тех глифов, которые всегда касаются этого верхнего края: символы 'H'
, 'T'
или 'X'
. А далее вычислим длину данного красного вектора, вычитая Bearing.y
из значения Bearing.y
любого из этих достигающих верхнего края глифов. Таким образом, мы сдвигаем глиф вниз в зависимости от того, насколько его высота отличается от высоты до верхнего края области:
1 |
float ypos = y + (this->Characters['H'].Bearing.y - ch.Bearing.y) * scale; |
В дополнение к обновлению расчета переменной ypos
мы также немного изменили порядок вершин, чтобы при умножении на текущую матрицу ортографической проекции все вершины по-прежнему были обращены к зрителю лицевой стороной (см. Урок №22. Отсечение граней).
Добавить использование класса TextRenderer в игру очень просто:
1 2 3 4 5 6 7 8 |
TextRenderer *Text; void Game::Init() { [...] Text = new TextRenderer(this->Width, this->Height); Text->Load("fonts/ocraext.TTF", 24); } |
Переменная Text
типа TextRenderer инициализируется шрифтом OCR A Extended, который вы можете скачать здесь. Если шрифт вам не нравится, смело используйте другой.
Игровые жизни
Вместо того, чтобы сразу же сбросить игру, как только мяч достигнет нижнего края, мы хотели бы дать игроку несколько дополнительных шансов. Реализуем это в виде игровых жизней, где игрок начинает с некоторым стартовым количеством жизней (скажем, 3), и каждый раз, когда мяч касается нижнего края окна, у игрока отнимается 1 жизнь. Только когда общее количество жизней игрока становится равным 0, мы сбрасываем игру. Благодаря этому, игроку будет легче пройти уровень, и в то же время создается некоторое игровое напряжение.
Мы будем вести подсчет жизней игрока с помощью соответствующей переменной, добавленной в класс Game (инициализируемой в конструкторе значением 3
):
1 2 3 4 5 6 |
class Game { [...] public: unsigned int Lives; } |
Затем модифицируем функцию Game::Update(), чтобы вместо сброса игры, уменьшить количество жизней игрока и сбросить игру только после того, как это количество станет равным 0
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
void Game::Update(float dt) { [...] if (Ball->Position.y >= this->Height) // мяч достиг нижнего края окна? { --this->Lives; // У игрока закончились все жизни? Game over! if (this->Lives == 0) { this->ResetLevel(); this->State = GAME_MENU; } this->ResetPlayer(); } } |
Как только игрок закончит игру (переменная Lives
станет равна 0
), мы сбросим уровень и изменим состояние игры на GAME_MENU
, которое обсудим чуть позже.
Не забудьте при сбросе игры/уровня восстановить стартовое количество жизней:
1 2 3 4 5 |
void Game::ResetLevel() { [...] this->Lives = 3; } |
Теперь игрок имеет набор жизней, но не имеет возможности увидеть, сколько их в данный момент у него осталось. Вот тут-то и появляется рендер текста:
1 2 3 4 5 6 7 8 9 |
void Game::Render() { if (this->State == GAME_ACTIVE) { [...] std::stringstream ss; ss << this->Lives; Text->RenderText("Lives:" + ss.str(), 5.0f, 5.0f, 1.0f); } } |
Мы преобразуем количество жизней в строку и выведем её в верхнем левом углу экрана. Теперь это будет выглядеть примерно так:
Как только мяч касается нижнего края экрана, общее количество жизней игрока уменьшается, и это мгновенно отображается в верхнем левом углу.
Выбор уровня
Всякий раз, когда игрок находится в игровом состоянии GAME_MENU
, мы хотели бы дать ему возможность выбрать уровень, на котором он хочет играть. С помощью кнопок w
или s
игрок может пролистывать загруженные нами уровни. Далее он должен нажать клавишу Enter, чтобы переключиться из состояния игры GAME_MENU
в состояние GAME_ACTIVE
.
Позволить игроку выбрать уровень не так уж и сложно. Всё, что нам нужно сделать, — это увеличить или уменьшить переменную Level
игрового класса в зависимости от того, нажал ли игрок кнопку w
или s
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
if (this->State == GAME_MENU) { if (this->Keys[GLFW_KEY_ENTER]) this->State = GAME_ACTIVE; if (this->Keys[GLFW_KEY_W]) this->Level = (this->Level + 1) % 4; if (this->Keys[GLFW_KEY_S]) { if (this->Level > 0) --this->Level; else this->Level = 3; } } |
Мы используем оператор остатка от деления (%
), чтобы убедиться, что переменная Level
находится в пределах допустимого диапазона значений (от 0
до 3
).
Также необходимо обозначить, что именно мы хотим рендерить, когда находимся в меню. Мы хотели бы дать игроку некоторые инструкции в виде текста, а также фоном отобразить выбранный уровень.
1 2 3 4 5 6 7 8 9 10 11 12 |
void Game::Render() { if (this->State == GAME_ACTIVE || this->State == GAME_MENU) { [...] // код рендеринга состояния игры } if (this->State == GAME_MENU) { Text->RenderText("Press ENTER to start", 250.0f, Height / 2, 1.0f); Text->RenderText("Press W or S to select level", 245.0f, Height / 2 + 20.0f, 0.75f); } } |
Здесь мы визуализируем игру всякий раз, когда находимся в состоянии GAME_ACTIVE
или GAME_MENU
, но при этом, когда мы находимся в состоянии GAME_MENU
, мы также рендерим две строки текста, чтобы сообщить игроку о том, что он должен выбрать уровень и/или принять его выбор. Обратите внимание, что для того, чтобы это работало при запуске игры, вы должны установить по умолчанию состояние игры как GAME_MENU
.
Выглядит великолепно, но запуская проект, вы, вероятно, заметите, что как только вы нажмете клавишу w
или s
, игра быстро прокручивается по уровням, что затрудняет их выбор. Это происходит потому, что она считывает нажатие кнопки каждый раз, пока мы её не отпустим. Это приводит к тому, что функция ProcessInput() обрабатывает нажатую клавишу более одного раза.
Мы можем решить эту проблему с помощью небольшого трюка, обычно встречающегося в GUI-системах. Хитрость заключается в том, чтобы не только записывать кнопки, которые в данный момент нажаты, но и хранить те кнопки, которые были обработаны один раз, пока они не будут снова отпущены. Затем мы проверяем (перед обработкой), не была ли кнопка обработана, и если — да, то обрабатываем её, после чего сохраняем как обработанную. Как только мы опять хотим обработать ту же самую кнопку, но без нажатия, — мы не обрабатываем её. Это, вероятно, звучит несколько запутанно, но как только вы увидите это на практике, вам сразу всё станет ясно.
Сначала мы должны создать еще один массив значений типа bool, чтобы указать, какие кнопки были обработаны. Мы определим его в классе Game:
1 2 3 4 5 6 |
class Game { [...] public: bool KeysProcessed[1024]; } |
Затем мы устанавливаем значение соответствующей кнопки в true
, как только она обработается, и удостоверяемся, что обрабатываем кнопку только в том случае, когда она не была обработана ранее:
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 Game::ProcessInput(float dt) { if (this->State == GAME_MENU) { if (this->Keys[GLFW_KEY_ENTER] && !this->KeysProcessed[GLFW_KEY_ENTER]) { this->State = GAME_ACTIVE; this->KeysProcessed[GLFW_KEY_ENTER] = true; } if (this->Keys[GLFW_KEY_W] && !this->KeysProcessed[GLFW_KEY_W]) { this->Level = (this->Level + 1) % 4; this->KeysProcessed[GLFW_KEY_W] = true; } if (this->Keys[GLFW_KEY_S] && !this->KeysProcessed[GLFW_KEY_S]) { if (this->Level > 0) --this->Level; else this->Level = 3; this->KeysProcessed[GLFW_KEY_S] = true; } } [...] } |
Теперь, если значение кнопки в массиве KeysProcessed
еще не задано, мы обрабатываем кнопку и устанавливаем значение переменной в true
. В следующий раз, когда мы достигнем if-условия для той же кнопки (а она уже будет к этому моменту обработана), мы притворимся и не будем замечать текущее нажатие до тех пор, пока кнопка снова не будет отпущена.
Затем в файле program.cpp внутри callback-функции key_callback() нам нужно сбросить статус обработки кнопки, как только она будет отпущена, чтобы при следующем нажатии мы могли её снова обработать:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode) { [...] if (key >= 0 && key < 1024) { if (action == GLFW_PRESS) Breakout.Keys[key] = true; else if (action == GLFW_RELEASE) { Breakout.Keys[key] = false; Breakout.KeysProcessed[key] = false; } } } |
Снова запуская игру, мы видим изящный экран выбора уровня, который теперь точно выбирает один уровень за одно нажатие кнопки, независимо от того, как долго мы её удерживаем в «нажатом» состоянии.
Условие выигрыша
В настоящее время игрок может выбирать уровни, играть в игру и проигрывать. Но если игрок узнает, что после уничтожения всех кирпичей он никоим образом не сможет выиграть игру, то это станет для него своего рода маленьким разочарованием. Так давайте исправим эту ситуацию.
Игрок выигрывает игру, когда все нетвердые блоки будут уничтожены. Мы уже создали функцию IsCompleted() в классе GameLevel для проверки данного условия:
1 2 3 4 5 6 7 |
bool GameLevel::IsCompleted() { for (GameObject &tile : this->Bricks) if (!tile.IsSolid && !tile.Destroyed) return false; return true; } |
Мы проверяем все кирпичи на игровом уровне, и, если есть хотя бы один неразрушенный нетвердый кирпич, возвращаем false
. Всё, что нам нужно сделать — это проверить наличие этого условия в функции Game::Update() и как только она вернет true
, мы изменим состояние игры на GAME_WIN
:
1 2 3 4 5 6 7 8 9 10 11 |
void Game::Update(float dt) { [...] if (this->State == GAME_ACTIVE && this->Levels[this->Level].IsCompleted()) { this->ResetLevel(); this->ResetPlayer(); Effects->Chaos = true; this->State = GAME_WIN; } } |
Всякий раз, при прохождении уровня, пока игра активна, мы сбрасываем игру и выводим небольшое сообщение о победе в состоянии GAME_WIN
. Для потехи мы также включим эффект «Хаоса», находясь на экране GAME_WIN
. В функции Game::Render() мы поздравим игрока и попросим его либо перезапустить игру, либо выйти из нее:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
void Game::Render() { [...] if (this->State == GAME_WIN) { Text->RenderText( "You WON!!!", 320.0, Height / 2 - 20.0, 1.0, glm::vec3(0.0, 1.0, 0.0) ); Text->RenderText( "Press ENTER to retry or ESC to quit", 130.0, Height / 2, 1.0, glm::vec3(1.0, 1.0, 0.0) ); } } |
После этого мы, конечно же, должны обработать нажатую кнопку:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
void Game::ProcessInput(float dt) { [...] if (this->State == GAME_WIN) { if (this->Keys[GLFW_KEY_ENTER]) { this->KeysProcessed[GLFW_KEY_ENTER] = true; Effects->Chaos = false; this->State = GAME_MENU; } } } |
Теперь, если вы достаточно хороши, чтобы действительно выиграть игру, вы получите следующее изображение:
И это всё! Последний кусочек игры «Breakout», над которой мы так активно работали, готов. Пришло время испробовать нашу игру. Настраивайте её так, как вам нравится, и покажите всем своим родным и друзьям!
GitHub / Часть №12: Рендеринг текста в игре «Breakout» на C++/OpenGL — Исходный код