Введение в программирование: заготовка игры-платформера на SDL в 300 строк C++

от автора

Этот текст предназначен для тех, кто только осваивает программирование. Я читаю лекции по C++ на первом курсе местного университета, и в качестве практикума предлагаю запрограммировать любую игру (не выношу проектов типа «софт бронирования книг в местной библиотеке»). Соответственно, чтобы помочь начинающим, я сделал некоторое количество заготовок, с которых можно стартовать свой проект. Например, заготовку олдскульного 3д шутера в 486 строк C++ я уже описывал, а вот тут можно посмотреть, что из неё сделали первокурсники.

В этот раз всё будет ещё проще, я хочу сделать заготовку под простейший платформер, вот так выглядит результат:

На данный момент проект содержит менее трёхсот строчек цпп:

ssloy@khronos:~/sdl2-demo/src$ cat *.cpp *.h | wc -l 296

Мой опыт показывает, что просто выложить код заготовки недостаточно. Нужно детально описать, как именно я пришёл к такому коду, ведь самый главный навык программиста — это суметь разбить сложную задачу на некоторое количество более простых подзадач, каждая из которых решается легко.

Итак, поехали!

Шаг первый: компилируем проект и открываем окно

Я ничего не знаю про оконные библиотеки, поэтому выбрал первую попавшуюся, а именно SDL. Первый шаг — самый сложный. Нужно суметь скомпилировать пустой проект, и слинковаться с выбранной библиотекой. Самая первая задача — открыть пустое окно, и отработать событие его закрытия. Вот тут можно найти соответствующий коммит:

image

Изначально я хотел сделать черновой репозиторий, и потом почистить историю, убрать детские баги, сделать красивый «один коммит на одну фичу», но тут случился анекдот: только я создал репозиторий, как он был немедленно форкнут парой человек, один из которых мне к тому же прислал пулл реквест . Соответственно, чистить историю, не сломав их репы, я уже не могу. Издержки двух с половиной тысяч фолловеров на гитхабе . Таким образом, у меня честный репозиторий без прикрас.

Сборочный файл CMakeLists.txt лучше взять из последней версии, он линкуется с SDL2, если найдёт его в системе, а в противном случае подтягивает его исходники, и компилирует его сам. Должно работать без сучка-задоринки как под линухом, так и под виндой.

Вот так выглядит код, открывающий пустое окно:

#include <iostream> #define SDL_MAIN_HANDLED #include <SDL.h>  void main_loop(SDL_Renderer *renderer) {     while (1) { // main game loop         SDL_Event event; // handle window closing         if (SDL_PollEvent(&event) && (SDL_QUIT==event.type || (SDL_KEYDOWN==event.type && SDLK_ESCAPE==event.key.keysym.sym)))             break; // quit         SDL_RenderClear(renderer); // re-draw the window         SDL_RenderPresent(renderer);     } }  int main() {     SDL_SetMainReady(); // tell SDL that we handle main() function ourselves, comes with the SDL_MAIN_HANDLED macro     if (SDL_Init(SDL_INIT_VIDEO)) {         std::cerr << "Failed to initialize SDL: " << SDL_GetError() << std::endl;         return -1;     }      SDL_Window   *window   = nullptr;     SDL_Renderer *renderer = nullptr;     if (SDL_CreateWindowAndRenderer(1024, 768, SDL_WINDOW_SHOWN | SDL_WINDOW_INPUT_FOCUS, &window, &renderer)) {         std::cerr << "Failed to create window and renderer: " << SDL_GetError() << std::endl;         return -1;     }     SDL_SetWindowTitle(window, "SDL2 game blank");     SDL_SetRenderDrawColor(renderer, 210, 255, 179, 255);      main_loop(renderer); // all interesting things happen here      SDL_DestroyRenderer(renderer);     SDL_DestroyWindow(window);     SDL_Quit();     return 0; }

Собственно, там ничего сверхъестественного: мы инициализируем SDL, создаём окно с зоной рендера и запускаем основной цикл игры. По событию закрытия окна или по нажатию эскейпа выходим из цикла и чистим память. Piece of cake.

Шаг второй: счётчик fps

На втором этапе я решил отобразить количество перерисовок экрана в секунду, хочу увидеть вот такой результат (не пугайтесь сорока тысяч fps, всё же мы ничего не делаем в основном цикле!):

Для этого нам нужно две вещи:

  • научиться считать количество перерисовок экрана в секунду
  • научиться его отображать на экране

Давайте начнём с отрисовки счётчика. Сперва я хотел рендерить текст при помощи библиотеки SDL_ttf, но потом выяснилось, что она тянет за собой ещё и другие зависимости, и мне стало лень автоматически собирать ещё и их, если они не установлены в системе. Поэтому я решил сделать существенно тупее: я нарисовал десять цифр размера 24×30 пикселей, и упаковал их в один .bmp файл размера 240 x 30 пикселей:

Всё едино мне нужно будет уметь работать со спрайтами, так почему бы не использовать эту же технику для отрисовки счётчика? Итак, вот структура для работы со спрайтами:

struct Sprite {     Sprite(SDL_Renderer *renderer, const std::string filename, const int width) : width(width) {         SDL_Surface *surface = SDL_LoadBMP((std::string(RESOURCES_DIR) + filename).c_str());         if (!surface) {             std::cerr << "Error in SDL_LoadBMP: " << SDL_GetError() << std::endl;             return;         }         if (!(surface->w%width) && surface->w/width) { // image width must be a multiple of sprite width             height  = surface->h;             nframes = surface->w/width;             texture = SDL_CreateTextureFromSurface(renderer, surface);         } else             std::cerr << "Incorrect sprite size" << std::endl;         SDL_FreeSurface(surface);     }      SDL_Rect rect(const int idx) const { // choose the sprite number idx from the texture         return { idx*width, 0, width, height };     }      ~Sprite() { // do not forget to free the memory!         if (texture) SDL_DestroyTexture(texture);     }      SDL_Texture *texture = nullptr; // the image is to be stored here     int width   = 0; // single sprite width (texture width = width * nframes)     int height  = 0; // sprite height     int nframes = 0; // number of frames in the animation sequence };

В переменных состояния объекта у нас указатель на непосредственно текстуру, ширина одного спрайта, высота спрайта, и количество спрайтов в текстуре. Конструктор просто подтягивает .bmp файл и проверяет, что его размеры совпадают с ожидаемым. Ну а метод rect(idx) позволяет выбрать спрайт с индексом idx для последующей его отрисовке в зоне рендера.

А теперь давайте поговорим про счётчик. Я создал структуру под названием FPS_Counter, и просто вызываю её метод .draw() внутри основного цикла:

void main_loop(SDL_Renderer *renderer) {     FPS_Counter fps_counter(renderer);     while (1) { // main game loop         [...]         SDL_RenderClear(renderer); // re-draw the window         fps_counter.draw();         SDL_RenderPresent(renderer);     } }

Метод .draw() ведёт подсчёт вызовов, и отрисовывает счётчик, используя подгруженные спрайты с цифрами. Давайте внимательно посмотрим на эту структуру. Основая идея — измерять количество вызовов .draw() раз в некоторое время (у меня триста миллисекунд). Соответственно, у меня есть два инта — fps_prev хранит последнее измеренное значение fps, а fps_cur это текущий счётчик. Ещё нужно хранить временную метку timestamp для отслеживания этих самых трёхсот миллисекунд. Вот так выглядит полный код структуры:

struct FPS_Counter {     FPS_Counter(SDL_Renderer *renderer) : renderer(renderer), numbers(renderer, "numbers.bmp", 24) {}      void draw() {         fps_cur++;         double dt = std::chrono::duration<double>(Clock::now() - timestamp).count();         if (dt>=.3) { // every 300 ms update current FPS reading             fps_prev = fps_cur/dt;             fps_cur = 0;             timestamp = Clock::now();         }         SDL_Rect dst = {4, 16, numbers.width, numbers.height}; // first character will be drawn here         for (const char c : std::to_string(fps_prev)) { // extract individual digits of fps_prev             SDL_Rect src = numbers.rect(c-'0'); // crude conversion of numeric characters to int: '7'-'0'=7             SDL_RenderCopy(renderer, numbers.texture, &src, &dst); // draw current digit             dst.x += numbers.width + 4; // draw characters left-to-right, +4 for letter spacing (TODO: add padding directly to the .bmp file)         }     }      int fps_cur  = 0; // the FPS readings are updated once in a while; fps_cur is the number of draw() calls since the last reading     int fps_prev = 0; // and here is the last fps reading     TimeStamp timestamp = Clock::now(); // last time fps_prev was updated     SDL_Renderer *renderer; // draw here     const Sprite numbers;   // "font" file };

fps_counter.draw(); inside main loop while(1) { ... }.

Вот тут можно посмотреть коммит с рабочим кодом.

Шаг третий: сорок тысяч fps это многовато, давайте поменьше

На данный момент у меня на ноуте вентиляторы крутятся так, что он порывается улететь. Давайте-ка снизим нагрузку на проц. Как заставить основной цикл исполняться не больше 50 раз в секунду? Самый наивный вариант — это что-то вроде такого кода:

    while (1) { // main game loop         do_something();         sleep(20);     } }

Мы можем тупо вставить задержку на 20 миллисекунд в тело цикла, получив максимум 50 fps. Такой подход имеет право на жизнь, но он предполагает, что время работы do_nothing() пренебрежимо. А если вдруг оно будет исполняться, скажем, за 12мс? Тогда нам задержку нужно не 20, а 8, иначе сильно проседает FSP. А ведь это ещё зависит от компа… Поэтому я предлагаю следующий подход:

    TimeStamp timestamp = Clock::now();     while (1) { // main game loop         double dt = std::chrono::duration<double>(Clock::now() - timestamp).count();         if (dt<.02) { // 50 FPS regulation             std::this_thread::sleep_for(std::chrono::milliseconds(1));             continue;         }         timestamp = Clock::now();         do_something();     } }

Мы просто храним временную метку timestamp, соответствующую последней отрисовке экрана, и не даём пройти внутрь цикла до тех пор, пока не истекут 20 миллисекунд. Задержка на 1мс вставлена для того, чтобы не грузить CPU на 100% пустыми проверками времени. Разумеется, в реальной игре за это время лучше делать что-нибудь полезное, считать физику, например.

Итак, вот результат:

Шаг четвёртый: отрисовываем уровень

Теперь давайте отрисуем карту уровня, вот тут соответствующий коммит. Я хочу видеть вот такой результат:

Для этого я сначала нарисовал текстуру 768 x 128 пикслей, в которую у меня упаковано шесть спрайтов каменюк размером 128×128:

Мой экран разбит на 192 клетки (16 по горизонтали и 12 по вертикали), и каждой клетке соответствует какая-то текстура.

Я создал структуру Map, которая используется следующим образом в основом цикле игры:

    Map map(renderer);     while (1) { // main game loop         [...]         SDL_RenderClear(renderer); // re-draw the window         map.draw();         SDL_RenderPresent(renderer);     }

Сама структура определена следующим образом:

struct Map {     Map(SDL_Renderer *renderer) : renderer(renderer), textures(renderer, "ground.bmp", 128) {         assert(sizeof(level) == w*h+1); // +1 for the null terminated string         int window_w, window_h;         if (!SDL_GetRendererOutputSize(renderer, &window_w, &window_h)) {             tile_w = window_w/w;             tile_h = window_h/h;         } else             std::cerr << "Failed to get renderer size: " << SDL_GetError() << std::endl;     }      void draw() { // draw the level in the renderer window         for (int j=0; j<h; j++)             for (int i=0; i<w; i++) {                 if (is_empty(i, j)) continue;                 SDL_Rect dst = { tile_w*i, tile_h*j, tile_w, tile_h };                 SDL_Rect src = textures.rect(get(i,j));                 SDL_RenderCopy(renderer, textures.texture, &src, &dst);             }     }      int get(const int i, const int j) const { // retreive the cell, transform character to texture index         assert(i>=0 && j>=0 && i<w && j<h);         return level[i+j*w] - '0';     }      bool is_empty(const int i, const int j) const {         assert(i>=0 && j>=0 && i<w && j<h);         return level[i+j*w] == ' ';     }      SDL_Renderer *renderer; // draw here     int tile_w = 0, tile_h = 0; // tile size in the renderer window      const Sprite textures;         // textures to be drawn     static constexpr int w = 16; // overall map dimensions, the array level[] has the length w*h+1 (+1 for the null character)     static constexpr int h = 12; // space character for empty tiles, digits indicate the texture index to be used per tile     static constexpr char level[w*h+1] = " 123451234012340"\                                          "5              5"\                                          "0              0"\                                          "5          5   5"\                                          "0          0   0"\                                          "512340   12345 5"\                                          "0              0"\                                          "5             51"\                                          "0     50      12"\                                          "5          51234"\                                          "0          12345"\                                          "1234012345052500"; };

Самое главное тут — массив level, который определяет, какой клетке соответствует какая текстура. В методе .draw() я прохожу по всем клеткам уровня, и для каждой незанятой отрисовываю соответствующий спрайт. Вспомогательные методы is_empty(i, j) и get(i, j) позволяют определить, пуста ли клетка с индексами i, j, и понять номер спрайта. Ну а в конструкторе я просто подтягиваю соответствующий .bmp файл и определяю размер клетки в пикселях экрана.

Шаг пятый: персонаж и его анимация

Осталось совсем немного: непосредственно персонаж. Давайте для начала научимся показывать анимации. Я взял карандаш и нарисовал последовательность кадров, которая показывает идущего человечка:

Я хочу получить вот такой результат (коммит брать тут):

Не бейте меня больно за кривые рисунки, я программист, а не художник! Как же нам их показать на экране? Для начала давайте опишем структуру, которая будет ответственна за анимации:

struct Animation : public Sprite {     Animation(SDL_Renderer *renderer, const std::string filename, const int width, const double duration, const bool repeat) :         Sprite(renderer, filename, width), duration(duration), repeat(repeat) {}      bool animation_ended(const TimeStamp timestamp) const { // is the animation sequence still playing?         double elapsed = std::chrono::duration<double>(Clock::now() - timestamp).count(); // seconds from timestamp to now         return !repeat && elapsed >= duration;     }      int frame(const TimeStamp timestamp) const { // compute the frame number at current time for the the animation started at timestamp         double elapsed = std::chrono::duration<double>(Clock::now() - timestamp).count(); // seconds from timestamp to now         int idx = static_cast<int>(nframes*elapsed/duration);         return repeat ? idx % nframes : std::min(idx, nframes-1);     }      SDL_Rect rect(const TimeStamp timestamp) const { // choose the right frame from the texture         return { frame(timestamp)*width, 0, width, height };     }      const double duration = 1; // duration of the animation sequence in seconds     const bool repeat = false; // should we repeat the animation? };

Анимация не особо отличается от простых спрайтов, поэтому я её и унаследовал от структуры Sprite. У неё два дополнительных члена: время проигрывания анимации и булевская переменная, которая говорит, нужно ли играть анимацию в цикле. Конструктор просто наследуется от конструктора Sprite, а дополнительные методы позволяют узнать, закончилось ли проигрывание (animation_ended(timestamp)), и получить текущий кадр анимации (frame(timestamp) + rect(timestamp)).

Теперь осталось описать персонажа:

struct Player {     enum States {         REST=0, TAKEOFF=1, FLIGHT=2, LANDING=3, WALK=4, FALL=5     };      Player(SDL_Renderer *renderer) :         renderer(renderer),         sprites{Animation(renderer, "rest.bmp",    256, 1.0, true ),                 Animation(renderer, "takeoff.bmp", 256, 0.3, false),                 Animation(renderer, "flight.bmp",  256, 1.3, false),                 Animation(renderer, "landing.bmp", 256, 0.3, false),                 Animation(renderer, "walk.bmp",    256, 1.0, true ),                 Animation(renderer, "fall.bmp",    256, 1.0, true )} {     }      void draw() {         SDL_Rect src = sprites[state].rect(timestamp);         SDL_Rect dest = { int(x)-sprite_w/2, int(y)-sprite_h, sprite_w, sprite_h };         SDL_RenderCopyEx(renderer, sprites[state].texture, &src, &dest, 0, nullptr, backwards ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE);     }      double x = 150, y = 200; // coordinates of the player     bool backwards = false;  // left or right      int state = WALK;     TimeStamp timestamp = Clock::now();      const int sprite_w = 256; // size of the sprite on the screen     const int sprite_h = 128;      SDL_Renderer *renderer;   // draw here     std::array<Animation,6> sprites; // sprite sequences to be drawn };

Я сразу сделал заготовку под то, что у персонажа будет несколько характерных состояний (ходьба, прыжок, падение). Положение игрока на экране я задаю переменными x и y, они соответствют середине подошвы. Направление лево/право задаётся булевской переменной backwards, а переменная timestamp задаёт метку времени начала проигрывания анимации.

Использование этой структуры пока что идентично использованию карты и счётчика fps:

    Player player(renderer);     while (1) { // main game loop         [...]         SDL_RenderClear(renderer); // re-draw the window         [...]         player.draw();         SDL_RenderPresent(renderer);     }

Шаг шестой: опрос клавиатуры и обработка столкновений

А теперь давайте научимся опрашивать клавиатуру и обрабатывать столкновения с картой (вот коммит). Я хочу получить вот такой результат:

Для обработки клавиатуры я добавил функцию handle_keyboard(), которая вызывает функцию смены состояния set_state() в зависимости от того, какие курсорные стрелки нажаты:

    void handle_keyboard() {         const Uint8 *kbstate = SDL_GetKeyboardState(NULL);         if (state==WALK && !kbstate[SDL_SCANCODE_RIGHT] && !kbstate[SDL_SCANCODE_LEFT])             set_state(REST);         if (state==REST && (kbstate[SDL_SCANCODE_LEFT] || kbstate[SDL_SCANCODE_RIGHT])) {             backwards = kbstate[SDL_SCANCODE_LEFT];             set_state(WALK);         }     }      void set_state(int s) {         timestamp = Clock::now();         state = s;         if (state==REST)             vx = 0;         if (state==WALK)             vx = backwards ? -150 : 150;     }

Для изменения положения на экране я вызываю функцию update_state, которая и занимается тем, что изменяет переменную состояния x:

    void update_state(const double dt, const Map &map) {         x += dt*vx; // candidate coordinates prior to collision detection         if (!map.is_empty(x/map.tile_w, y/map.tile_h)) { // horizontal collision detection             int snap = std::round(x/map.tile_w)*map.tile_w; // snap the coorinate to the boundary of last free tile             x = snap + (snap>x ? 1 : -1);              // be careful to snap to the left or to the right side of the free tile             vx = 0; // stop         }     }

Для начала я считаю координату на следующем шаге: x = x + dt*vx, а затем проверяю, не попадает ли эта координата в заполненную клетку карты. Если такое случается, то я останавливаю персонажа, и обновляю x таким образом, чтобы она оказалась на границе заполненной клетки.

Шаг седьмой: сила тяжести!

Сила тяжести добавляется элементарно, мы выписываем абсолютно такое же поведение и для вертикальной координаты, лишь добавив ещё и увеличение вертикальной скорости vy += dt*300, где 300 — это ускорение свободного падения в 300 пикселей в секунду за секунду.

    void update_state(const double dt, const Map &map) {         [...]         y  += dt*vy;  // prior to collision detection         vy += dt*300;   // gravity         [...]     }

Обработку столкновения по вертикали даже описывать не буду, она ничем не отличается от горизонтали, коммит брать тут, ну а вот и результат:

Последние штрихи

Единственное, что осталось добавить нашу заготовку — это обработку прыжков. Это делается заданием начальных скоростей vx и vy в зависимости от комбинации курсорных клавиш. Чисто по приколу у меня есть прыжки в высоту и прыжки в длину:

Заключение

Ну вот, собственно, и всё. Как я и обещал, игры как таковой у меня нет, но есть играбельная демка всего из 296 строк кода.

Моей задачей было лишь объяснить основные принципы построения примитивного платформера, и мне очень любопытно будет посмотреть, что выдадут мои студенты в этом году. Любые комментарии и идеи приветствуются, как по улучшению этого кода, так и по поводу того, что можно сделать ещё.

Have fun!


ссылка на оригинал статьи https://habr.com/ru/articles/578018/