Сегодня мы создадим аналог популярной игры «Змейка» на С++/Qt5.
Игра «Змейка»
Игра «Змейка» — это старая классическая видеоигра. Впервые она была создана в конце 70-х годов для использования на игровых автоматах, а затем в 1979 году её перенесли и на ПК. В этой игре игрок управляет тонким существом, похожим на змею, которое перемещается по игровому полю без остановки. Цель состоит в том, чтобы съесть как можно больше яблок, появляющихся в процессе игры. Каждый раз, когда змея съедает яблоко, она становится длиннее, что усложняет дальнейший процесс игры. Змея при этом должна избегать столкновений со стенами и собственным телом.
Разработка игры «Змейка»
Размер каждой отдельной «части» тела змеи составляет 10 пикселей. Управляется она с клавиатуры при помощи стрелочек: ←
, →
, ↑
, ↓
. Изначально тело змеи состоит из трех «частей». Если игра завершилась, то в центре игрового поля отображается сообщение "Game Over"
.
Заголовочный файл — Snake.h:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
#pragma once #include <QWidget> #include <QKeyEvent> class Snake : public QWidget { public: Snake(QWidget *parent = 0); protected: void paintEvent(QPaintEvent *); void timerEvent(QTimerEvent *); void keyPressEvent(QKeyEvent *); private: QImage dot; QImage head; QImage apple; static const int B_WIDTH = 300; static const int B_HEIGHT = 300; static const int DOT_SIZE = 10; static const int ALL_DOTS = 900; static const int RAND_POS = 29; static const int DELAY = 140; int timerId; int dots; int apple_x; int apple_y; int x[ALL_DOTS]; int y[ALL_DOTS]; bool leftDirection; bool rightDirection; bool upDirection; bool downDirection; bool inGame; void loadImages(); void initGame(); void locateApple(); void checkApple(); void checkCollision(); void move(); void doDrawing(); void gameOver(QPainter &); }; |
Константы B_WIDTH
и B_HEIGHT
определяют размеры (ширину и высоту) игрового поля. DOT_SIZE
— это размеры яблока и «частей» тела змеи. Константа ALL_DOTS
определяет максимальное количество возможных точек на доске (900 = (300*300)/(10*10)
). Константа RAND_POS
используется для вычисления случайной позиции яблока, а константа DELAY
определяет скорость игры:
1 2 3 4 5 6 |
static const int B_WIDTH = 300; static const int B_HEIGHT = 300; static const int DOT_SIZE = 10; static const int ALL_DOTS = 900; static const int RAND_POS = 29; static const int DELAY = 140; |
Следующие два массива содержат координаты (x;y)
всех «частей» тела змеи:
1 2 |
int x[ALL_DOTS]; int y[ALL_DOTS]; |
В файле snake.cpp содержится логика нашей игры.
Файл реализации — snake.cpp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
#include <QPainter> #include <QTime> #include "Snake.h" Snake::Snake(QWidget *parent) : QWidget(parent) { setStyleSheet("background-color:black;"); leftDirection = false; rightDirection = true; upDirection = false; downDirection = false; inGame = true; resize(B_WIDTH, B_HEIGHT); loadImages(); initGame(); } void Snake::loadImages() { dot.load("dot.png"); head.load("head.png"); apple.load("apple.png"); } void Snake::initGame() { dots = 3; for (int z = 0; z < dots; z++) { x[z] = 50 - z * 10; y[z] = 50; } locateApple(); timerId = startTimer(DELAY); } void Snake::paintEvent(QPaintEvent *e) { Q_UNUSED(e); doDrawing(); } void Snake::doDrawing() { QPainter qp(this); if (inGame) { qp.drawImage(apple_x, apple_y, apple); for (int z = 0; z < dots; z++) { if (z == 0) { qp.drawImage(x[z], y[z], head); } else { qp.drawImage(x[z], y[z], dot); } } } else { gameOver(qp); } } void Snake::gameOver(QPainter &qp) { QString message = "Game over"; QFont font("Courier", 15, QFont::DemiBold); QFontMetrics fm(font); int textWidth = fm.width(message); qp.setPen(QColor(Qt::white)); qp.setFont(font); int h = height(); int w = width(); qp.translate(QPoint(w/2, h/2)); qp.drawText(-textWidth/2, 0, message); } void Snake::checkApple() { if ((x[0] == apple_x) && (y[0] == apple_y)) { dots++; locateApple(); } } void Snake::move() { for (int z = dots; z > 0; z--) { x[z] = x[(z - 1)]; y[z] = y[(z - 1)]; } if (leftDirection) { x[0] -= DOT_SIZE; } if (rightDirection) { x[0] += DOT_SIZE; } if (upDirection) { y[0] -= DOT_SIZE; } if (downDirection) { y[0] += DOT_SIZE; } } void Snake::checkCollision() { for (int z = dots; z > 0; z--) { if ((z > 4) && (x[0] == x[z]) && (y[0] == y[z])) { inGame = false; } } if (y[0] >= B_HEIGHT) { inGame = false; } if (y[0] < 0) { inGame = false; } if (x[0] >= B_WIDTH) { inGame = false; } if (x[0] < 0) { inGame = false; } if(!inGame) { killTimer(timerId); } } void Snake::locateApple() { QTime time = QTime::currentTime(); qsrand((uint) time.msec()); int r = qrand() % RAND_POS; apple_x = (r * DOT_SIZE); r = qrand() % RAND_POS; apple_y = (r * DOT_SIZE); } void Snake::timerEvent(QTimerEvent *e) { Q_UNUSED(e); if (inGame) { checkApple(); checkCollision(); move(); } repaint(); } void Snake::keyPressEvent(QKeyEvent *e) { int key = e->key(); if ((key == Qt::Key_Left) && (!rightDirection)) { leftDirection = true; upDirection = false; downDirection = false; } if ((key == Qt::Key_Right) && (!leftDirection)) { rightDirection = true; upDirection = false; downDirection = false; } if ((key == Qt::Key_Up) && (!downDirection)) { upDirection = true; rightDirection = false; leftDirection = false; } if ((key == Qt::Key_Down) && (!upDirection)) { downDirection = true; rightDirection = false; leftDirection = false; } QWidget::keyPressEvent(e); } |
При помощи метода loadImages() мы загружаем изображения, которые будут использоваться в игре. Класс QImage
используется для хранения данных PNG-изображений:
1 2 3 4 5 |
void Snake::loadImages() { dot.load("dot.png"); head.load("head.png"); apple.load("apple.png"); } |
В методе initGame() мы создаем змею, случайным образом определяем позицию на игровом поле, где будет располагаться яблоко, и запускаем таймер:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
void Snake::initGame() { dots = 3; for (int z = 0; z < dots; z++) { x[z] = 50 - z * 10; y[z] = 50; } locateApple(); timerId = startTimer(DELAY); } |
Если голова сталкивается с яблоком, то мы увеличиваем количество «частей» тела змеи. Затем вызываем метод locateApple(), который случайным образом позиционирует новое яблоко:
1 2 3 4 5 6 7 8 |
void Snake::checkApple() { if ((x[0] == apple_x) && (y[0] == apple_y)) { dots++; locateApple(); } } |
В методе move() у нас находится ключевой алгоритм игры. Чтобы понять его, посмотрите, как движется змея. Мы контролируем только голову змеи. При этом мы можем изменить её направление движения с помощью стрелочек. Остальные «части» тела змеи по цепочке перемещаются друг за другом. Вторая «часть» движется туда, где была первая, третья — туда, где была вторая и т.д.:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
void Snake::move() { for (int z = dots; z > 0; z--) { x[z] = x[(z - 1)]; y[z] = y[(z - 1)]; } if (leftDirection) { x[0] -= DOT_SIZE; } if (rightDirection) { x[0] += DOT_SIZE; } if (upDirection) { y[0] -= DOT_SIZE; } if (downDirection) { y[0] += DOT_SIZE; } } |
Перемещаем «части» тела змеи друг за другом:
1 2 3 4 |
for (int z = dots; z > 0; z--) { x[z] = x[(z - 1)]; y[z] = y[(z - 1)]; } |
Голова перемещается в любом из 4 направлений. Например, перемещаем голову змеи влево:
1 2 3 |
if (leftDirection) { x[0] -= DOT_SIZE; } |
В методе checkCollision() мы определяем, столкнулась ли змея со стеной или со своим телом. Если змея ударится головой о какую-нибудь «часть» своего тела, то игра окончена:
1 2 3 4 5 |
for (int z = dots; z > 0; z--) { if ((z > 4) && (x[0] == x[z]) && (y[0] == y[z])) { inGame = false; } } |
Игра заканчивается, если змея сталкивается с любой частью доски, например, с нижней:
1 2 3 |
if (y[0] >= B_HEIGHT) { inGame = false; } |
Метод timerEvent() формирует игровой цикл. При условии, что игра еще не закончена, мы выполняем обнаружение столкновений змеи с препятствиями и выполняем её дальнейшее перемещение. Функция repaint() вызывает перерисовку окна:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
void Snake::timerEvent(QTimerEvent *e) { Q_UNUSED(e); if (inGame) { checkApple(); checkCollision(); move(); } repaint(); } |
Например, если пользователь нажимает стрелочку ←
, то мы устанавливаем значение true
для переменной leftDirection
. Эта переменная используется в функции move() для изменения координат змеи. Стоит отметить, что когда змея направляется вправо, мы не можем сразу же повернуть налево (т.е. сразу развернуться на 180 градусов):
1 2 3 4 5 |
if ((key == Qt::Key_Left) && (!rightDirection)) { leftDirection = true; upDirection = false; downDirection = false; } |
Основной файл программы — main.cpp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <QApplication> #include "Snake.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); Snake window; window.setWindowTitle("Snake"); window.show(); return app.exec(); } |
Результат выполнения программы:
GitHub / Урок №14. Создание игры «Змейка» на C++/Qt5 — Исходный код
Заключение
Вот такой вот несложный, но интересный урок. На следующем уроке мы попробуем создать еще одну популярную игру — «Арканоид».
Мое решение на Qt6 в среде MS VS 2022 Enterprise Edition, некоторые моменты реализовал совершенно по-другому, возможно будет кому-то полезным:
Snake.h
Snake.cpp
main.cpp
Спасибо. Ваши уроки проясняют многое в плюсах). Я сейчас осваиваю С++ в контексте Unreal Engine. Игра "Змейка" у меня готова, но есть один баг движения: Например, когда змейка движется влево, если нажать вверх и затем сразу вправо, то змейка не успевает поменять направление вверх и сразу уходит вправо, тем самым врезаясь в саму себя. Не подскажете, как убрать сей баг? Как запретить уходить вправо, пока все элементы змейки не сместились вверх?
Моя змейка:
https://gitlab.com/atrushilev/snakegame/-/tree/master/Source/SnakeGame
Буду признателен за любую помощь или совет:)
Баг возникает из-за того, что игра перерисовывается по timerEvent раз в 140мс, в то время как keyPressEvent принимает нажатие клавиш неограниченное количество раз. Значит прописанное в нем условие запрета на обратное движение снимается нажатием перед этим другой клавиши.
Можно в keyPressEvent нажатую клавишу просто сохранять в int переменную, а всю обработку перенести в timerEvent — выше вызова метода move.
Это все чушь , создайте в каталоге папку images
поместите в нее файлы изображений , в проекте создайте ресурс с префиксом, например snake , добавьте к нему картинки
внесите следующие исправление в loadImages()
и все закрутится 🙂
Увеличил изображение с 10px до 24px, в итоге когда змея попадает на яблоко, оно не пропадает и змея не становится больше.
Можете подробнее расписать про gameOver функцию?
А какие конкретно места в функции gameOver() у вас вызывают вопросы? 🙂
Пробую добавить плюшки в игру.
Например кнопку старт в начале игры. В конструктор прописал
Но ругается на виртуальную таблицу методов. В чем может быть дело?
Извините, пишу второй раз, потому что отчаялся, уже 5 часов пытаюсь сделать так, чтобы картинка прогрузилась, ничего не помогает.
Насколько я понял, это часть кода, из-за которой моя змейка невидимая
Да, проблема в том, что у Qt не получается подгрузить данные файлы. Решить её можно несколькими способами:
Способ 1:
Пропишите полные пути к файлам-картинкам, Например:
Не забудьте при этом проверить, чтобы у вас была создана папка C:\Snake и в ней лежали эти 3 файла.
Способ 2:
Закройте Qt Creator. Найдите у себя в скачанных исходниках файл My_QtApplication.pro.user. Удалите данный файл. После этого снова откройте проект в Qt Creator и запустите его.
Способ 3: Перекачайте исходники с сайта. Там эта ошибка уже исправлена.