Tetris на C и GTK 2.0

Сегодня напишем свой Tetris на C и GTK 2.0. В ходе данного процесса будем осуществлять программирование «сверху», то есть от общего идти к частному. Код для данного проекта довольно прост за счет сравнительно эффективного деления алгоритма на подзадачи.

Tetris C++ GTK

Структуры данных

Начнем с того, что определим глобальные переменные для нашей реализации данной игры. Как можно описать процессы и данные для игры Tetris? Нужно хранить информацию о том — что находится в данный момент на игровом поле. Так как для начала будем учитывать только сам факт наличия «кирпича» в конкретном месте, нам достаточно одного байта для клетки. Если байт нулевой — клетка пуста, иначе — в ней «кирпич». Таким образом получаем следующее:

#define FIELD_HEIGHT 20
#define FIELD_WIDTH 13
char main_field[FIELD_HEIGHT][FIELD_WIDTH];

То есть наше поле — это двумерный массив из байт. В данном случае размеры массива 20х13 клеток (20 по вертикали и 13 по горизонтали).

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

#define FIGURE_SIZE       5
#define FIGURE_COUNT 7
#define BLOCK_SIZE 20

char main_figure[FIGURE_SIZE][FIGURE_SIZE];

char figures[FIGURE_COUNT][FIGURE_SIZE*FIGURE_SIZE] = {
{ 0,0,0,0,0,
0,0,0,0,0,
1,1,1,1,1,
0,0,0,0,0,
0,0,0,0,0 }, ...

В указанном фрагменте main_figure — та фигура, что движется в данный момент по полю. figures — массив из шаблонов возможных фигур. Максимальный размер одной фигуры — 5х5 кирпичей. Всего у меня получилось 7 разных фигур, что отражено в константе FIGURE_COUNT. Здесь BLOCK_SIZE — размер кирпича в пикселях. Надо понимать, что во фрагменте продемонстрирована только одна фигура — горизонтальная планка.

Помимо описанных параметров, необходимо знать координаты фигуры. Определим их через следующие глобальные переменные:

int main_figure_x, main_figure_y;

Так как игра может стать довольно динамичной из-за увеличения её скорости, игрок должен иметь возможность ставить процесс на «паузу». Введем переменную для этого:

gboolean isGamePaused = FALSE;

Осталось определить переменные для количества очков и скорости игры.

int main_score = 0;
int main_speed = 1;

Графика

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

GtkWidget* win = 0;
GtkWidget* dr_area;
GtkWidget* labelScore;
GtkWidget* btnNewGame;
GtkWidget* btnPause;

Для рисования на «скрытой» поверхности с последующим её показом потребуется соответствующий буфер.

static GdkPixmap* pixmap = NULL;

Скорость падения фигуры

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

Для разрешения данной проблемы воспользуемся функциями из time.h. Идея в следующем: получаем количество срабатываний таймера; если с момента прошлого измерения прошло достаточное количество срабатываний таймера, передвигаем фигуру вниз и обновляем данные об указанном количестве.

clock_t current_time;

...

if((clock() - current_time) > (25 - main_speed)*200) {
moveDownFigure();
current_time = clock();
...

От общего к частному

Новая игра

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

static void newGameClicked(GtkWidget* widget, gpointer data) {
main_score = 0;
main_speed = 1;
clear_field();
clear_figure();
dropNewFigure();
isGamePaused = FALSE;
};

Начинаем углубляться в процесс. Мы на данном этапе не определили методы clear_field, clear_figure и dropNewFigure, но из названий понятно — что они должны делать. Определим каждый из них.

void clear_field() {
memset(main_field, 0, FIELD_WIDTH*FIELD_HEIGHT*sizeof(char));
};

void clear_figure() {
memset(main_figure, 0, FIGURE_SIZE*FIGURE_SIZE*sizeof(char));
};

Здесь обнуляем выделенную под фигуру и поле память. Далее на очереди функция dropNewFigure. Почему именно функция? Дело в том, что от возможности «выбросить» фигуру будет зависеть исход игры.

Выброс фигуры

gboolean dropNewFigure() {
main_figure_x = FIELD_WIDTH/2 - FIGURE_SIZE/2;
main_figure_y = 0;
movFigure(main_figure, &figures[rand() % FIGURE_COUNT][0]);
if(!checkPlace(main_figure)) return FALSE;
return TRUE;
};

Будем «выбрасывать» фигуру сверху посередине. Далее в память, соответствующую падающей фигуре загружаем шаблон, выбранный случайным образом. Если фигура не поместилась на поле, возвращаем FALSE, иначе TRUE.

void movFigure(char* dest, char* src) {
memcpy(dest, src, FIGURE_SIZE*FIGURE_SIZE*sizeof(char));
};

Ничего особенного, просто копируем участок памяти из источника в приемник.

Tetris и контроль столкновений

gboolean checkPlace(char* f) {
for(int i=0; i<FIGURE_SIZE; i++) {
for(int j=0; j<FIGURE_SIZE; j++) {
int x = j + main_figure_x;
int y = i + main_figure_y;
if(f[i*FIGURE_SIZE+j] != 0) {
if(x < 0 ||
x >= FIELD_WIDTH ||
y < 0 || y >= FIELD_HEIGHT)
return FALSE;
if(main_field[y][x] != 0)
return FALSE;
};
};
};
return TRUE;
};

Если фигура, расположенная в памяти по адресу-аргументу, не пересекает границы поля и уже имеющиеся на поле блоки (кирпичи), функция возвращает TRUE. В противном случае — FALSE. Считается, что координаты фигуры — (main_figure_x, main_figure_y).

Организация игрового цикла

Рассмотрим теперь основной игровой цикл. Данный цикл можно организовать с помощью таймера в GTK (не путать с таймером clock()) . Для этого установим функцию для обработки его срабатываний.

g_timeout_add(50, TimerProc, NULL);

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

Что должно быть в игровом цикле? Если игра на «паузе», не делаем ничего. В противном случае определяем количество времени, прошедшего с момента предыдущего вызова. Если прошел достаточный интервал, перемещаем фигуру вниз и обновляем текущее время. Далее производим отрисовку поля и фигуры. Отметим, что отрисовка не производится только если игра на паузе. Это сделано для того, чтобы видеть результат действий игрока (перевернул фигуру, переместил влево-вправо).

gboolean TimerProc(gpointer data) {
if(isGamePaused) return TRUE;
if( (clock() - current_time) > (25-main_speed)*200)
{
moveDownFigure();
current_time = clock();
};

drawField();
drawFigure();
gtk_widget_queue_draw_area(dr_area, 0, 0,
dr_area->allocation.width, dr_area->allocation.height);
return TRUE;
};

Возвращаем TRUE для того, чтобы gtk-таймер продолжал функционировать. Метод gtk_widget_queue_draw_area отправляет системе запрос на отрисовку соответствующей части виджета. В нашем случае это игровое поле целиком.

Падение фигуры

Напоминаю, что в конкретный момент код методов moveDownFigure, drawField и drawFigure мне не известен. Единственное, что известно — это то, что первая процедура должна передвигать фигуру вниз со всеми вытекающими последствиями, вторая процедура должная нарисовать поле с имеющимися на нем кирпичами, а третья — отобразить падающую фигуру поверх всего нарисованного.

void moveDownFigure() {
main_figure_y++;
if(!checkPlace(main_figure)) {
main_figure_y--;
plantFigure();
if(!dropNewFigure()) gameOver();
};
};

Тут всё просто: передвигаем фигуру на клетку вниз. Если фигура «врезалась» в кирпичи или нижнюю часть поля, поднимаем её на прежнее место и размещаем на поле. И тут как раз пригодилось свойство функции dropNewFigure сигнализировать о возможности выбросить новую конструкцию. Если выброс не удался, значит всё заполнено доверху, а игра проиграна.

К счастью, нам нужно доопределить только методы plantFigure и gameOver.

void plantFigure() {
for(int i=0; i<FIGURE_SIZE; i++)
for(int j=0; j<FIGURE_SIZE; j++) {
int x = main_figure_x + j;
int y = main_figure_y + i;
if(main_figure[i][j] != 0 &&
x >= 0 && y >= 0 &&
x < FIELD_WIDTH && y < FIELD_HEIGHT)
main_field[y][x] = 1;
};
checkLines();
};

Размещаем фигуру на поле в соответствии с её координатами. Так как после этого картина на поле поменялась, имеет смысл проверить — появились ли полностью заполненные строки. И, если таковые имеются, удалить их, начислив соответствующее количество очков.

Проверка заполнения строк и начисление очков

void checkLines() {
char txt[30];
int lines = 0;
gboolean refresh = FALSE;
for(int i=FIELD_HEIGHT-1; i>=0; i--)
while(checkLine(i)) {
shiftLine(i);
lines++;
refresh = TRUE;
};
if(!refresh) return;
main_score += lines*lines;
main_speed += lines > 0 ? 1 : 0;
sprintf(txt, "Score: %06d", main_score);
gtk_label_set(GTK_LABEL(labelScore), txt);
};

Двигаемся по полю снизу вверх. Пока (и если) текущая строка заполнена, удаляем её, сдвигая содержимое поля на одну строку вниз. При этом считаем количество удаленных строк. В зависимости от данного количества начисляем очки. Формируем строку с информацией о текущем счете и выводим как текст соответствующей надписи.

void shiftLine(int k) {
for(int i=k; i>0; i--)
for(int j=0; j<FIELD_WIDTH; j++)
main_field[i][j] = main_field[i-1][j];
};
gboolean checkLine(int k) {
for(int i=0; i<FIELD_WIDTH; i++)
if(main_field[k][i] == 0)
return FALSE;
return TRUE;
};

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

void gameOver() {
char txt[50];
pauseGame();
sprintf(txt, "-- Game Over --\nFinal Score: %06d",
main_score);
gtk_label_set(GTK_LABEL(labelScore), txt);
clear_field();
clear_figure();
main_score = 0;
main_speed = 1;
dropNewFigure();
};

Вращение фигуры

Подробно расписывать данный процесс я не стану. Скажу только, что суть заключается в повороте матрицы main_figure по часовой стрелке.

void rotateFigure() {
char* t = createFigure();
getRotFigure(t, main_figure);
if(checkPlace(t))
movFigure(main_figure, t);
deleteFigure(t);
};

Здесь выделяем память под перевернутую фигуру, получаем её за счет метода getRotFigure. Если результат поворота может быть размещен, считаем его основной фигурой и освобождаем память. Данные методы довольно тривиальны и их реализацию можно будет увидеть в исходниках к данной статье.

Функции рисования блоков

Требования и подготовка

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

dr_area = gtk_drawing_area_new();
gtk_drawing_area_size(dr_area, BLOCK_SIZE*FIELD_WIDTH,
BLOCK_SIZE*FIELD_HEIGHT);

gtk_signal_connect(GTK_OBJECT(dr_area), "configure_event",
(GtkSignalFunc)(configure_event), NULL); gtk_signal_connect(GTK_OBJECT(dr_area), "expose_event",
(GtkSignalFunc)(expose_event), NULL);

gtk_widget_set_events(dr_area, GDK_EXPOSURE_MASK);

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

Рисование поля

void drawField() {
for(int i=0; i<FIELD_HEIGHT; i++)
for(int j=0; j<FIELD_WIDTH; j++)
if(main_field[i][j] == 0)
draw_brushw(dr_area, j, i);
else draw_brush(dr_area, j, i);
};

Если ячейка поля пуста — рисуем на её месте белый (условно) квадрат, иначе рисуем темный (опять же условно).

static void draw_brushw(GtkWidget* widget, gdouble x, gdouble y) {
GdkRectangle update_rect;
update_rect.x = x*BLOCK_SIZE;
update_rect.y = y*BLOCK_SIZE;
update_rect.width = BLOCK_SIZE;
update_rect.height = BLOCK_SIZE;
gdk_draw_rectangle(pixmap,
widget->style->bg_gc[GTK_STATE_ACTIVE],
TRUE, update_rect.x, update_rect.y,
update_rect.width, update_rect.height);
};

Функция, указанная выше, является модифицированной версией функции draw_brush из следующего руководства. draw_brush отличается только одним параметром в gdk_draw_rectangle, а именно widget->style->fg_gc[GTK_STATE_ACTIVE]. То есть цвета блоков выбираются из стандартных цветов выбранной оконной темы.

Рисование фигуры

В данном методе нет ничего нового. Все механизмы, по сути, описаны выше.

void drawFigure() {
for(int i=0; i<FIGURE_SIZE; i++)
for(int j=0; j<FIGURE_SIZE; j++) {
int x = main_figure_x + j;
int y = main_figure_y + i;
if((main_figure[i][j] != 0) &&
(x >= 0) && (y >= 0) &&
(x < FIELD_WIDTH) && (y < FIELD_HEIGHT))
draw_brush(dr_area, x, y);
};
};

Итоги

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

Движения фигуры из стороны в сторону происходят по нажатию клавиш управления курсором. Переворот — по нажатию клавиши «стрелка вверх». Поставить игру на паузу можно, нажав клавишу «p» или соответствующую кнопку в окне игры.

Исходники

Скачать

Добавить комментарий

Ваш e-mail не будет опубликован.