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

Kate

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


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



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


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

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


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

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


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


88be6395fe1e712e509423f545bd3815.png

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


Сборочный файл 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, всё же мы ничего не делаем в основном цикле!):
145b5073b1029b2c7d89e2ba5d771ffa.gif



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


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

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


f2cb7db64fb068941515b20778b5b796.png



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


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


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


4fccf955c5cc7eef64cf3fe3a3d890a8.gif



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


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


ab90dc469e7965c598eebd23d502b782.png

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


7b26f883201b8575bf15365170700f3e.png

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


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


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


2129235b7b67771f2c857391a2ed39ba.png



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


e8547dbdaf4c4d0bc63512d523d4ac51.gif



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


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

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


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


c622b4fe9ea20fe3610bc8ab454a8a78.gif



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

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


6b2f418ef1701ae3c1a7a75531230267.gif



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


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


aa57b6a2c9897a5096d1218f653d6b38.gif



Заключение​


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


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


 
Сверху