В этой серии постов я собираюсь создать клон классической аркадной игры Космические захватчики на C++, используя всего несколько зависимостей. В этом посте я добавлю обработку ввода игрока с клавиатуры и стрельбу снарядами.
Полный код этого поста можно найти здесь.
Реализация обратного вызова ключа для GLFW
GLFW использует обратные вызовы для передачи важных событий, как и тот, который мы реализовали для отлова ошибок. Неудивительно, что нам нужно реализовать соответствующую функцию обратного вызова для перехвата входных событий. Обратный вызов должен иметь следующую подпись:
typedef void(*GLFWkeyfun)(GLFWwindow*, int, int, int, int)
и устанавливается вызовом функции GLFW glfwSetKeyCallback
.
Давайте сначала реализуем простой обратный вызов клавиши для перехвата нажатия клавиши Esc
. Когда мы обнаружим, что эта клавиша нажата, мы выйдем из игры. Таким образом, мы также можем быстро протестировать наш обратный вызов. Для этого мы добавляем глобальную переменную,
bool game_running = false;
который мы установили в true
перед основным циклом. Кроме того, в основном цикле мы проверяем, является ли переменная по-прежнему true
,
while (!glfwWindowShouldClose(window) && game_running)
если нет, цикл выходит и игра завершается. Наконец, мы реализуем обратный вызов ключа,
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mods){ switch(key){ case GLFW_KEY_ESCAPE: if(action == GLFW_PRESS) game_running = false; break; default: break; } }
здесь mods
указывает, были ли нажаты какие-либо модификаторы клавиш, такие как Shift
, Ctrl
и т. д. scancode
— это системный код клавиши, который мы не используем. Наконец, при инициализации окон мы устанавливаем обратный вызов GLFW,
glfwSetKeyCallback(window, key_callback);
Если все сделано правильно, вы сможете выйти из игры, нажав Esc
.
Добавление движения игрока
Чтобы сделать вещи более интересными, мы добавим движение игрока с помощью клавиш со стрелками влево и вправо на клавиатуре. Сначала мы добавляем глобальную переменную, указывающую направление движения,
int move_dir = 0;
Мы делаем это, присваивая значение +1 для правой стрелки и -1 для левой. Если одна из клавиш нажата, ее значение прибавляется к move_dir
, а если отпустить, то вычитается. Если, например. обе клавиши нажаты, move_dir = 0
. Мы реализуем эту логику, добавляя два дополнительных случая переключения к нашему обратному вызову ключа,
case GLFW_KEY_RIGHT: if(action == GLFW_PRESS) move_dir += 1; else if(action == GLFW_RELEASE) move_dir -= 1; break; case GLFW_KEY_LEFT: if(action == GLFW_PRESS) move_dir -= 1; else if(action == GLFW_RELEASE) move_dir += 1; break;
В основном цикле, после отрисовки игры, мы добавляем логику для обновления позиции игрока на основе ввода ключа,
int player_move_dir = 2 * move_dir; if(player_move_dir != 0) { if(game.player.x + player_sprite.width + player_move_dir >= game.width) { game.player.x = game.width - player_sprite.width; } else if((int)game.player.x + player_move_dir <= 0) { game.player.x = 0; } else game.player.x += player_move_dir; }
Во внутренних операторах if мы сначала проверяем случаи, когда игрок находится близко к границе. В этом случае мы ограничиваем движение спрайта игрока, чтобы он оставался внутри границ игры.
Добавление стрельбы снарядами
Добавление стрельбы снарядами/пулями немного сложнее. Как и в предыдущем разделе, мы добавляем глобальную переменную, указывающую, была ли нажата кнопка запуска,
bool fire_pressed = 0;
и привяжите запуск к Space
, добавив еще один случай переключения к обратному вызову ключа,
case GLFW_KEY_SPACE: if(action == GLFW_RELEASE) fire_pressed = true; break;
Существуют разные способы реализации стрельбы снарядами. Думаю, изначально в игре мог присутствовать только один снаряд. Здесь я решил стрелять снарядом каждый раз, когда игрок отпускает кнопку. Затем мы добавляем новый struct
для снарядов, как мы делали это для игрока, пришельцев и т. д.
struct Bullet { size_t x, y; int dir; };
где знак dir
указывает направление движения снаряда, т.е. летит ли он вверх, в сторону пришельцев (+), или вниз, в сторону игрока (-). Надеюсь, вы извините меня за то, что в коде снаряды называются «пулями», но именно это я и использовал изначально. Мы определяем максимально возможные снаряды, которые может отображать игра, и добавляем их в игру struct
,
#define GAME_MAX_BULLETS 128 struct Game { size_t width, height; size_t num_aliens; size_t num_bullets; Alien* aliens; Player player; Bullet bullets[GAME_MAX_BULLETS]; };
При инициализации игры мы устанавливаем количество пуль, game.num_bullets = 0
, и добавляем спрайт для снаряда,
Sprite bullet_sprite; bullet_sprite.width = 1; bullet_sprite.height = 3; bullet_sprite.data = new uint8_t[3] { 1, // @ 1, // @ 1 // @ };
которую, как вы можете видеть, мы в настоящее время рисуем в виде небольшой вертикальной линии. В основном цикле мы рисуем снаряды аналогично тому, как мы рисуем пришельцев,
for(size_t bi = 0; bi < game.num_bullets; ++bi) { const Bullet& bullet = game.bullets[bi]; const Sprite& sprite = bullet_sprite; buffer_draw_sprite(&buffer, sprite, bullet.x, bullet.y, rgb_to_uint32(128, 0, 0)); }
После рисования мы обновляем позиции снарядов, добавляя dir
, и удаляем все снаряды, вылетающие из игровой области, используя распространенную технику, когда мы перезаписываем удаляемый элемент последним в массиве,
for(size_t bi = 0; bi < game.num_bullets;) { game.bullets[bi].y += game.bullets[bi].dir; if(game.bullets[bi].y >= game.height || game.bullets[bi].y < bullet_sprite.height) { game.bullets[bi] = game.bullets[game.num_bullets - 1]; --game.num_bullets; continue; } ++bi; }
Наконец, запуск снарядов игроком обрабатывается в конце основного цикла.
if(fire_pressed && game.num_bullets < GAME_MAX_BULLETS) { game.bullets[game.num_bullets].x = game.player.x + player_sprite.width / 2; game.bullets[game.num_bullets].y = game.player.y + player_sprite.height; game.bullets[game.num_bullets].dir = 2; ++game.num_bullets; } fire_pressed = false;
где мы устанавливаем направление снаряда, dir
, на +2
Нам нужно больше пришельцев для вторжения
На данный момент снаряды не интерактивны, они просто летят сквозь рой инопланетян и в бездну! Если мы хотим остановить инопланетное вторжение, нам лучше сделать снаряды менее слабыми. Но перед этим давайте сначала добавим больше типов инопланетян. В Space Invaders есть 3 типа пришельцев, кроме того, мы реализуем смерть пришельцев, добавив еще один тип пришельцев, мертвых пришельцев. Мы реализуем это в нашем коде, используя перечисление,
enum AlienType: uint8_t { ALIEN_DEAD = 0, ALIEN_TYPE_A = 1, ALIEN_TYPE_B = 2, ALIEN_TYPE_C = 3 };
и добавьте в Alien флаг struct
, указывающий на тип пришельца. Затем мы сохраняем спрайты пришельцев в виде массива, проиндексированного по типу пришельца и индексу кадра анимации.
Sprite alien_sprites[6]; alien_sprites[0].width = 8; alien_sprites[0].height = 8; alien_sprites[0].data = new uint8_t[64] { 0,0,0,1,1,0,0,0, // ...@@... 0,0,1,1,1,1,0,0, // ..@@@@.. 0,1,1,1,1,1,1,0, // .@@@@@@. 1,1,0,1,1,0,1,1, // @@.@@.@@ 1,1,1,1,1,1,1,1, // @@@@@@@@ 0,1,0,1,1,0,1,0, // .@.@@.@. 1,0,0,0,0,0,0,1, // @......@ 0,1,0,0,0,0,1,0 // .@....@. }; alien_sprites[1].width = 8; alien_sprites[1].height = 8; alien_sprites[1].data = new uint8_t[64] { 0,0,0,1,1,0,0,0, // ...@@... 0,0,1,1,1,1,0,0, // ..@@@@.. 0,1,1,1,1,1,1,0, // .@@@@@@. 1,1,0,1,1,0,1,1, // @@.@@.@@ 1,1,1,1,1,1,1,1, // @@@@@@@@ 0,0,1,0,0,1,0,0, // ..@..@.. 0,1,0,1,1,0,1,0, // .@.@@.@. 1,0,1,0,0,1,0,1 // @.@..@.@ }; alien_sprites[2].width = 11; alien_sprites[2].height = 8; alien_sprites[2].data = new uint8_t[88] { 0,0,1,0,0,0,0,0,1,0,0, // ..@.....@.. 0,0,0,1,0,0,0,1,0,0,0, // ...@...@... 0,0,1,1,1,1,1,1,1,0,0, // ..@@@@@@@.. 0,1,1,0,1,1,1,0,1,1,0, // .@@.@@@.@@. 1,1,1,1,1,1,1,1,1,1,1, // @@@@@@@@@@@ 1,0,1,1,1,1,1,1,1,0,1, // @.@@@@@@@.@ 1,0,1,0,0,0,0,0,1,0,1, // @.@.....@.@ 0,0,0,1,1,0,1,1,0,0,0 // ...@@.@@... }; alien_sprites[3].width = 11; alien_sprites[3].height = 8; alien_sprites[3].data = new uint8_t[88] { 0,0,1,0,0,0,0,0,1,0,0, // ..@.....@.. 1,0,0,1,0,0,0,1,0,0,1, // @..@...@..@ 1,0,1,1,1,1,1,1,1,0,1, // @.@@@@@@@.@ 1,1,1,0,1,1,1,0,1,1,1, // @@@.@@@.@@@ 1,1,1,1,1,1,1,1,1,1,1, // @@@@@@@@@@@ 0,1,1,1,1,1,1,1,1,1,0, // .@@@@@@@@@. 0,0,1,0,0,0,0,0,1,0,0, // ..@.....@.. 0,1,0,0,0,0,0,0,0,1,0 // .@.......@. }; alien_sprites[4].width = 12; alien_sprites[4].height = 8; alien_sprites[4].data = new uint8_t[96] { 0,0,0,0,1,1,1,1,0,0,0,0, // ....@@@@.... 0,1,1,1,1,1,1,1,1,1,1,0, // .@@@@@@@@@@. 1,1,1,1,1,1,1,1,1,1,1,1, // @@@@@@@@@@@@ 1,1,1,0,0,1,1,0,0,1,1,1, // @@@..@@..@@@ 1,1,1,1,1,1,1,1,1,1,1,1, // @@@@@@@@@@@@ 0,0,0,1,1,0,0,1,1,0,0,0, // ...@@..@@... 0,0,1,1,0,1,1,0,1,1,0,0, // ..@@.@@.@@.. 1,1,0,0,0,0,0,0,0,0,1,1 // @@........@@ }; alien_sprites[5].width = 12; alien_sprites[5].height = 8; alien_sprites[5].data = new uint8_t[96] { 0,0,0,0,1,1,1,1,0,0,0,0, // ....@@@@.... 0,1,1,1,1,1,1,1,1,1,1,0, // .@@@@@@@@@@. 1,1,1,1,1,1,1,1,1,1,1,1, // @@@@@@@@@@@@ 1,1,1,0,0,1,1,0,0,1,1,1, // @@@..@@..@@@ 1,1,1,1,1,1,1,1,1,1,1,1, // @@@@@@@@@@@@ 0,0,1,1,1,0,0,1,1,1,0,0, // ..@@@..@@@.. 0,1,1,0,0,1,1,0,0,1,1,0, // .@@..@@..@@. 0,0,1,1,0,0,0,0,1,1,0,0 // ..@@....@@.. }; Sprite alien_death_sprite; alien_death_sprite.width = 13; alien_death_sprite.height = 7; alien_death_sprite.data = new uint8_t[91] { 0,1,0,0,1,0,0,0,1,0,0,1,0, // .@..@...@..@. 0,0,1,0,0,1,0,1,0,0,1,0,0, // ..@..@.@..@.. 0,0,0,1,0,0,0,0,0,1,0,0,0, // ...@.....@... 1,1,0,0,0,0,0,0,0,0,0,1,1, // @@.........@@ 0,0,0,1,0,0,0,0,0,1,0,0,0, // ...@.....@... 0,0,1,0,0,1,0,1,0,0,1,0,0, // ..@..@.@..@.. 0,1,0,0,1,0,0,0,1,0,0,1,0 // .@..@...@..@. };
Обратите внимание, что мы также добавили спрайт смерти. Чтобы отслеживать смерти инопланетян, мы создаем массив счетчиков смертей,
uint8_t* death_counters = new uint8_t[game.num_aliens]; for(size_t i = 0; i < game.num_aliens; ++i) { death_counters[i] = 10; }
в каждом кадре, если инопланетянин мертв, мы уменьшаем счетчик смертей и «удаляем» пришельца из игры, когда счетчик достигает 0. При рисовании пришельцев нам теперь нужно проверять, больше ли счетчик смертей 0, иначе нам не нужно рисовать инопланетянина. Таким образом, спрайт смерти отображается в течение 10 кадров.
for(size_t ai = 0; ai < game.num_aliens; ++ai) { if(!death_counters[ai]) continue; const Alien& alien = game.aliens[ai]; if(alien.type == ALIEN_DEAD) { buffer_draw_sprite(&buffer, alien_death_sprite, alien.x, alien.y, rgb_to_uint32(128, 0, 0)); } else { const SpriteAnimation& animation = alien_animation[alien.type - 1]; size_t current_frame = animation.time / animation.frame_duration; const Sprite& sprite = *animation.frames[current_frame]; buffer_draw_sprite(&buffer, sprite, alien.x, alien.y, rgb_to_uint32(128, 0, 0)); } }
Обратите внимание, что мы дополнительно обновили цикл рисования, чтобы отрисовывать соответствующий спрайт инопланетянина в зависимости от типа инопланетянина. Счетчики смертей обновляются в цикле перед обновлением снарядов.
for(size_t ai = 0; ai < game.num_aliens; ++ai) { const Alien& alien = game.aliens[ai]; if(alien.type == ALIEN_DEAD && death_counters[ai]) { --death_counters[ai]; } }
Крутые парни не смотрят на взрывы
Наконец-то все готово для реализации самого приятного элемента в Space Invaders; взрывать инопланетян! Чтобы реализовать это, мы должны проверять, попала ли пуля в живого инопланетянина, при обновлении его положения,
for(size_t ai = 0; ai < game.num_aliens; ++ai) { const Alien& alien = game.aliens[ai]; if(alien.type == ALIEN_DEAD) continue; const SpriteAnimation& animation = alien_animation[alien.type - 1]; size_t current_frame = animation.time / animation.frame_duration; const Sprite& alien_sprite = *animation.frames[current_frame]; bool overlap = sprite_overlap_check( bullet_sprite, game.bullets[bi].x, game.bullets[bi].y, alien_sprite, alien.x, alien.y ); if(overlap) { game.aliens[ai].type = ALIEN_DEAD; // NOTE: Hack to recenter death sprite game.aliens[ai].x -= (alien_death_sprite.width - alien_sprite.width)/2; game.bullets[bi] = game.bullets[game.num_bullets - 1]; --game.num_bullets; continue; } }
Мы перебираем всех инопланетян и используем функцию sprite_overlap_check
, чтобы проверить, не перекрываются ли два спрайта. Мы делаем это, просто проверяя, перекрываются ли прямоугольники спрайта,
bool sprite_overlap_check( const Sprite& sp_a, size_t x_a, size_t y_a, const Sprite& sp_b, size_t x_b, size_t y_b ) { if(x_a < x_b + sp_b.width && x_a + sp_a.width > x_b && y_a < y_b + sp_b.height && y_a + sp_a.height > y_b) { return true; } return false; }
Если спрайт пули перекрывается спрайтом пришельца, мы удаляем пулю из игры и меняем тип пришельца на ALIEN_DEAD
. Обратите внимание, что мы используем случайный способ повторного центрирования спрайта смерти, поскольку мы неправильно обрабатываем центрирование спрайта в нашей игре.
Если вы скомпилируете код этого поста, вы сможете взорвать некоторых инопланетян!
Заключение
В этом посте настройте самую важную часть того, что делает игру игрой; интерактивность. С помощью GLFW мы привязали клавиши для перемещения игрока и стрельбы снарядами. Несмотря на то, что игра все еще находится в очень незавершенном состоянии, мы уже можем повеселиться, стреляя пулями в инопланетян. Наслаждение процессом очень важно в разработке игр, и именно оно побуждает экспериментировать с новыми идеями. Вы видите, что мы можем создать прототип игры на низкоуровневом языке программирования с минимальными усилиями. Что еще более важно, мы можем повторно использовать этот код для создания прототипов других 2D-игр.
В следующем посте мы создадим необходимые инструменты для рисования основного текста на экране, а также будем отслеживать и рисовать счет игрока.