Этот текст предназначен для тех, кто только осваивает программирование. Я читаю лекции по 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!