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

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