Space Invaders «с нуля» — часть 4: обработка ввода и механика стрельбы

Четвёртая часть цикла о создании клонов классических аркадных игр. В этой статье вы узнаете, как реализовать управление игроком с клавиатуры и добавить стрельбу в Space Invaders на C++. Разбираем обработку событий GLFW, движение, столкновения и снаряды.

67 открытий1К показов
Space Invaders «с нуля» — часть 4: обработка ввода и механика стрельбы

Это перевод статьи автора Nick Tasios. Мы уже публиковали перевод первой части с настройками окна и контекста, перевод второй части с настройкой шедеров и отрисовкой спрайта и перевод третьей части про создание клона игры на C++ с минимумом зависимостей. В этой части добавим обработку ввода от игрока с клавиатуры и механику стрельбы снарядами. Код четвёртой части можно посмотреть в репозитории на Github.

Реализация обработки нажатий клавиш в GLFW

GLFW использует callback-функции (обратные вызовы) для передачи важных событий — аналогично той, которую мы реализовали ранее для перехвата ошибок. Поэтому неудивительно, что нам нужно создать соответствующую callback-функцию для обработки событий ввода.

Callback-функция должна иметь следующую сигнатуру:

			typedef void(*GLFWkeyfun)(GLFWwindow*, int, int, int, int)
		

и устанавливается с помощью функции GLFW glfwSetKeyCallback.

Сначала реализуем простую callback-функцию для обработки нажатия клавиши Esc. Когда мы определим, что эта клавиша нажата, игра будет завершаться. Так мы сможем быстро проверить, что обратный вызов работает корректно.

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

			bool game_running = false;
		

Мы устанавливаем для неё значение true перед запуском основного игрового цикла. Дополнительно, внутри главного цикла мы проверяем, остаётся ли эта переменная равной true.

Если она изменится (например, после нажатия клавиши Esc), это будет сигналом к завершению работы программы.

			while (!glfwWindowShouldClose(window) && game_running)
		

если нет — цикл завершается, происходит корректный выход из игры.

Наконец, реализуем саму callback-функцию обработки клавиш, которая будет вызываться при каждом событии нажатия или отпускания клавиши.

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

И наконец, при инициализации окна мы устанавливаем callback-функцию GLFW, чтобы она вызывалась при каждом событии клавиатуры.

			glfwSetKeyCallback(window, key_callback);
		

Если всё сделано правильно, вы сможете выйти из игры, нажав клавишу Esc.

Добавляем движение игрока

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

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

			int move_dir = 0;
		

Мы делаем это, присваивая значение +1 для стрелки вправо и –1 для стрелки влево.

Если одна из клавиш нажата, её значение добавляется к move_dir; если отпущена — вычитается. Например, если обе клавиши нажаты одновременно, то move_dir = 0.

Эту логику реализуем, добавив два дополнительных случая (case) в наш callback функции обработки клавиш.

			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;
		

В основном цикле, после отрисовки игры, мы добавляем логику для обновления позиции игрока на основе нажатий клавиш.

Это делается просто: в каждой итерации игрового цикла мы проверяем значение move_dir и изменяем координату player.x в соответствии с направлением движения — например, уменьшая её при движении влево и увеличивая при движении вправо.

Таким образом, позиция игрока будет постоянно обновляться в зависимости от ввода с клавиатуры.

			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;
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), добавив ещё один блок switch case в функцию обработки нажатий клавиш (key callback).

			case GLFW_KEY_SPACE:
    if(action == GLFW_RELEASE) fire_pressed = true;
    break;
		

Существует несколько способов реализовать стрельбу снарядами. Изначально, насколько я помню, в игре мог присутствовать только один снаряд одновременно.

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

Затем мы добавляем новую структуру для снарядов (projectiles) — аналогично тому, как ранее создавались структуры для игрока (Player), пришельцев (Aliens) и других игровых объектов.

			struct Bullet
{
    size_t x, y;
    int dir;
};
		

Переменная dir указывает направление движения снаряда — вверх (в сторону пришельцев, значение +) или вниз (в сторону игрока, значение -).

Прошу прощения за то, что в коде я называю снаряды «Bullets» (пули) — это связано с тем, что изначально я использовал именно это обозначение.

Далее мы определяем максимальное количество снарядов, которые могут одновременно отображаться в игре, и добавляем их в структуру Game, чтобы управлять ими наряду с игроком и пришельцами.

			#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,

а также добавляем спрайт для снаряда (projectile), который будет использоваться при его отрисовке на экране.

			Sprite bullet_sprite;
bullet_sprite.width = 1;
bullet_sprite.height = 3;
bullet_sprite.data = new uint8_t[3]
{
    1, // @
    1, // @
    1  // @
};
		

Как вы можете видеть, сейчас снаряд отображается в виде небольшой вертикальной линии.

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

			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 (направление движения).

Если какой-то снаряд выходит за пределы игрового поля, мы удаляем его из массива активных объектов. Для этого используется распространённый приём: вместо сдвига всех элементов массива, удаляемый элемент перезаписывается последним элементом массива, а общее количество снарядов (num_bullets) уменьшается на единицу.

Такой способ позволяет эффективно управлять массивом без избыточных операций копирования и обеспечивает стабильную производительность даже при большом числе активных снарядов.

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

Наконец, обработка выстрелов игрока выполняется в конце основного игрового цикла (main loop).

На этом этапе программа проверяет, нажата ли клавиша стрельбы (в нашем случае — пробел) и есть ли место для добавления нового снаряда в массив пуль. Если оба условия выполнены, создаётся новый объект снаряда: ему присваиваются начальные координаты (позиция игрока), направление движения вверх (dir = +1) и он добавляется в список активных снарядов.

Так реализуется базовая механика стрельбы, при которой каждый выстрел инициируется действием игрока в конце цикла обновления игры.

			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 присутствует три типа пришельцев, отличающихся только спрайтами. Кроме того, мы добавим ещё один тип — «мертвого пришельца», чтобы реализовать анимацию уничтожения.

В коде это удобно реализовать при помощи перечисления (enum), где каждый элемент будет представлять тип пришельца — например, ALIEN_TYPE1, ALIEN_TYPE2, ALIEN_TYPE3 и ALIEN_DEAD.

Такое решение делает код более читаемым и упрощает работу с логикой поведения, отрисовки и анимации каждого типа врагов.

			enum AlienType: uint8_t
{
    ALIEN_DEAD   = 0,
    ALIEN_TYPE_A = 1,
    ALIEN_TYPE_B = 2,
    ALIEN_TYPE_C = 3
};
		

Добавляем флаг в структуру Alien, который указывает тип пришельца.

Затем мы сохраняем спрайты пришельцев в виде массива, индексируемого по типу пришельца и индексу кадра анимации. Таким образом, каждый тип врага (например, обычный, продвинутый или «мертвый») может иметь собственный набор спрайтов, а также отдельные кадры для анимации движения.

Такой подход делает код компактным и позволяет легко обновлять внешний вид или поведение конкретного типа пришельца без дублирования логики для каждого спрайта.

			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  // .@..@...@..@.
};
		

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

Чтобы отслеживать момент гибели каждого пришельца и управлять временем показа этого спрайта, мы создаём массив счётчиков смерти (death counters). Каждый элемент массива соответствует одному пришельцу и хранит значение, показывающее, сколько игровых циклов прошло с момента его уничтожения.

Это позволит постепенно убирать уничтоженных врагов с экрана после короткой анимации, создавая плавный визуальный эффект, характерный для оригинальных аркадных игр.

			uint8_t* death_counters = new uint8_t[game.num_aliens];
for(size_t i = 0; i < game.num_aliens; ++i)
{
    death_counters[i] = 10;
}
		

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

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

Таким образом, спрайт смерти отображается примерно 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));
    }
}
		

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

Счётчики смерти (death counters) обновляются в отдельном цикле, который выполняется до обновления снарядов.

			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.

Обратите внимание: мы используем упрощённый способ выравнивания спрайта смерти, поскольку в игре пока не реализована корректная обработка центрирования спрайтов.

Если вы скомпилируете код, представленный в этом разделе, вы сможете взорвать парочку пришельцев!

Space Invaders «с нуля» — часть 4: обработка ввода и механика стрельбы 1

Заключение

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

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

Мы увидели, что можно создать прототип игры на низкоуровневом языке программирования с минимальными усилиями. Более того, этот код легко переиспользовать для других 2D-проектов.

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

Следите за новыми постами
Следите за новыми постами по любимым темам
67 открытий1К показов