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

в 15:21, , рубрики: c++, Программирование, программирование для начинающих, разработка игр

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

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

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

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

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

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

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

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

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

Изначально я хотел сделать черновой репозиторий, и потом почистить историю, убрать детские баги, сделать красивый "один коммит на одну фичу", но тут случился анекдот: только я создал репозиторий, как он был немедленно форкнут парой человек, один из которых мне к тому же прислал пулл реквест Введение в программирование: заготовка игры-платформера на SDL в 300 строк C++ - 3. Соответственно, чистить историю, не сломав их репы, я уже не могу. Издержки двух с половиной тысяч фолловеров на гитхабе Введение в программирование: заготовка игры-платформера на SDL в 300 строк C++ - 4. Таким образом, у меня честный репозиторий без прикрас.

Сборочный файл 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 в 300 строк C++ - 5

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

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

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

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

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

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% пустыми проверками времени. Разумеется, в реальной игре за это время лучше делать что-нибудь полезное, считать физику, например.

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

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

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

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

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

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

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

Мой экран разбит на 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 файл и определяю размер клетки в пикселях экрана.

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

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

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

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

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

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

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);
    }

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

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

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

Для обработки клавиатуры я добавил функцию 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
        [...]
    }

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

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

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

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

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

Заключение

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

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

Have fun!

Автор:
haqreu

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js