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

На данный момент проект содержит менее трёхсот строчек цпп:
ssloy@khronos:~/sdl2-demo/src$ cat *.cpp *.h | wc -l 296
Мой опыт показывает, что просто выложить код заготовки недостаточно. Нужно детально описать, как именно я пришёл к такому коду, ведь самый главный навык программиста — это суметь разбить сложную задачу на некоторое количество более простых подзадач, каждая из которых решается легко.
Итак, поехали!
Шаг первый: компилируем проект и открываем окно
Я ничего не знаю про оконные библиотеки, поэтому выбрал первую попавшуюся, а именно SDL. Первый шаг — самый сложный. Нужно суметь скомпилировать пустой проект, и слинковаться с выбранной библиотекой. Самая первая задача — открыть пустое окно, и отработать событие его закрытия. Вот тут можно найти соответствующий коммит:

Изначально я хотел сделать черновой репозиторий, и потом почистить историю, убрать детские баги, сделать красивый "один коммит на одну фичу", но тут случился анекдот: только я создал репозиторий, как он был немедленно форкнут парой человек, один из которых мне к тому же прислал пулл реквест
. Соответственно, чистить историю, не сломав их репы, я уже не могу. Издержки двух с половиной тысяч фолловеров на гитхабе
. Таким образом, у меня честный репозиторий без прикрас.
Сборочный файл 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, но потом выяснилось, что она тянет за собой ещё и другие зависимости, и мне стало лень автоматически собирать ещё и их, если они не установлены в системе. Поэтому я решил сделать существенно ��упее: я нарисовал десять цифр размера 24x30 пикселей, и упаковал их в один .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 пикслей, в которую у меня упаковано шесть спрайтов каменюк размером 128x128:

Мой экран разбит на 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!
