В этой серии постов я собираюсь создать клон классической аркадной игры Космические захватчики на 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-игр.

В следующем посте мы создадим необходимые инструменты для рисования основного текста на экране, а также будем отслеживать и рисовать счет игрока.