Урок 5

В котором программа становится реал-тайм игрой с графическим выводом, и обзаводится счётчиком очков и гейм-овером.
"Линукс-геймерам больше нравится играть в запуск игр, нежели в сами игры"

Главный цикл программы из четвёртого урока разросся, и стоит его сделать поаккуратнее. В главном цикле не нужно выполнять все вычисления непосредственно, главный цикл занимается тем, что вызывает действия, которые стоит выпольнять на каждом шаге работы программы. Выделим отдельные функции для отрисовки фона и вывода буфера изображения, и заодно добавим функцию для отрисовки главного персонажа - и тогда главный цикл будеть лишь вызывать эти три функции, вся же настоящая работа будет происходить уже в них. Играть предстоит за самолётик, который к концу урока станет лететь направо, к своему недостижимому горизонту, уворачиваясь от препятствий. Обычно в играх камеру стараются расположить так, чтобы хорошо обозревалось пространство перед актором (главным героем, аватаром игрока). В силу технических причин не всегда это удаётся, но нам пока ничто не мешает расположить самолётик с левого края экрана - летит он направо, и так препятствия на пути будут видны заранее, до того, как актор "долетит" до них. чтобы не рисовать самолётик впритык к краю (где он иначе бы визуально сливался с границей окна) введём горизонтальное смещение MAINCHAR_X - количество отступа в символах от левой грани экрана до самолётика. Сам самолёт пока что будем отрисовывать символом "V":

#include <stdio.h>
#include <signal.h>

#define WIDTH 80 
#define HEIGHT 24 
#define DAMPING 500  

#define MAINCHAR_X 10 

int main_cycle;                    /* stop program if 0 */
unsigned int t;                    /* background offset */

char screen[(WIDTH + 1) * HEIGHT]; /* One extra char for linebreak */

void handler(int dummy)
{
    main_cycle = 0;
}

void init_screen()
{
    for (int y = 0; y < HEIGHT; y++)
    {
        for (int x = 0; x < WIDTH; x++)
        {
            screen[(WIDTH + 1) * y + x] = ' ';
        }
        screen[(WIDTH + 1) * y + WIDTH] = '\n';
    }
    screen[(WIDTH + 1) * HEIGHT - 1] = '\0';
}

void draw_pixel(int x, int y, char p)
{
    if (x < WIDTH && y < HEIGHT)
    {
        screen[(WIDTH + 1) * y + x] = p;
    }
}

void init_terminal()
{
    printf("\033[?1049h");         /* Enables buffer mode in terminal */
    printf("\033[?47h");           /* Tells terminal to save screen */
    printf("\033[?25l");           /* Hide cursor */
    printf("\033[2J");             /* Erase console screen  */
}

void restore_terminal()
{
    printf("\033[?25h");           /* Restore cursor visibility */
    printf("\033[?47h");           /* Restore pre-program screen */
    printf("\033[?1049l");         /* Disables buffer mode in terminal */
}

void draw_background()
{
    init_screen();                 /* Erase old image from buffer */
    for (int x = 1; x < WIDTH; x++)
    {
        int y = HEIGHT * x / WIDTH;
        int x_tmp = (x + t / DAMPING) % WIDTH;

        draw_pixel(x_tmp, y, '#');
    }
}

void draw_player()
{
    draw_pixel(MAINCHAR_X, HEIGHT / 2, 'V');
}

void render_scene()
{
    printf("\033[H");              /* Set cursor to upper-left position */
    printf("%s", screen);          /* Draws "screen" */
}


int main()
{
    signal(SIGINT, handler);       /* Registers handler() for SIGINT */
    main_cycle = 1;                /* Initializing runprogram flag */
    init_terminal();
    t = 0;

    /* Main cycle */
    while(main_cycle)
    {
        draw_background();
        draw_player();
        render_scene();
        t--;
    }

    restore_terminal();
    return 0;
}
(Текст программы разрастается, далее он не будет приводиться весь целиком, делее будут показываться только изменения в коде)

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

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

Для сдвига изображения понадобилась так же функция check_pixel() - она является парной функции draw_pixel(), но не помещает символ на холст, а возвращает значение пикселя холста. Рисование правой грани экрана доверено формуле (t / DAMPING) % (HEIGHT * GAP_WIDTH) - если вы не понимаете её суть, попробуйте разобраться или реализовать свой механизм отрисовки колонн. Формула же расчитывает появление очередного символа колонны на соответствующей высоте в зависимости от переменной t - то есть от времени, и заодно здесь вводится константа GAP_WIDTH, регулирующая расстояние между колоннами.

...

#define GAP_WIDTH 3

...

char check_pixel(int x, int y)
{
    char result = '\0';
    if (x < WIDTH && y < HEIGHT)
    {
        result = screen[(WIDTH + 1) * y + x];
    }
    return result;
}

...

void draw_background()
{
    if (t % DAMPING == 0)          /* Every DAMPING'th frame */
    {
        /* scrolls all screen */
        for (int x = 0; x < WIDTH - 1; x++)
        {
            for (int y = 0; y < HEIGHT; y++)
            {
                char pixel = check_pixel(x + 1, y);
                draw_pixel(x, y, pixel);
            }
        }
        /* construct right edge */
        int block_y = (t / DAMPING) % (HEIGHT * GAP_WIDTH);
        if (block_y >= 0)
        {
            /* draw new column element */
            draw_pixel(WIDTH - 1, block_y, '#');
            /* and erase old one */
            draw_pixel(WIDTH - 1, block_y - 1, ' ');
        }
    }
}

...

int main()
{
    signal(SIGINT, handler);       /* Registers handler() for SIGINT */
    main_cycle = 1;                /* Initializing runprogram flag */
    init_terminal();               /* Prepairing output device */
    init_screen();                 /* Have to do it once at start */
    t = 0;                         /* Initializing tick counter */

    /* Main cycle */
    while(main_cycle)
    {
        draw_background();
        draw_player();
        render_scene();
        t++;
    }

    restore_terminal();            /* Free output device */
    return 0;
}
Можно убедиться, что код проявляет себя почти как раньше - но символ самолётика быстро заполнила "хвост" до края экрана, и также - экран стал скроллиться быстрее.

Подкрутить скорость скроллинга экрана просто, для этого нужно подобрать соответствующее значение константы DAMPING, например для меня комфортным оказывается следующее значение:

#define DAMPING 1000

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

void draw_background()
{
    if (t % DAMPING == 0)          /* Every DAMPING'th frame */
    {
        /* scrolls all screen */
        for (int x = 0; x < WIDTH - 1; x++)
        {
            for (int y = 0; y < HEIGHT; y++)
            {
                char pixel = check_pixel(x + 1, y);
                /* particle system */
                if (pixel == 'V')
                {
                    pixel = '.';
                }
                draw_pixel(x, y, pixel);
            }
        }
        /* construct right edge */
        int block_y = (t / DAMPING) % (HEIGHT * GAP_WIDTH);
        if (block_y >= 0)
        {
            /* draw new column element */
            draw_pixel(WIDTH - 1, block_y, '#');
            /* and erase old one */
            draw_pixel(WIDTH - 1, block_y - 1, ' ');
        }
    }
}

Самолёт будет подвержен гравитации - заведём под сохранение вектора вертикальной скорости переменную character_velocity, и гравитационную постоянную GRAVITY. Функция draw_player() начинает включать в себя логику обработки персонажа - поэтому стоит переименовать её из draw_player() в handle_player(). Чтобы противостоять гравитации, самолёт обзаведётся двигателем - импульс двигателя будет применяться в противоположном гравитации направлении. Заодно применим импульс с самого старта программы - чтобы самолёт не начал падать с самого начала игры.
Будем проверять, не вышел ли самолёт за границы экрана сверху или снизу - при выходе за границы экрана сразу же закончим программу.

...

#define GRAVITY 0.02f  
#define START_POSITION HEIGHT / 2;
#define ENGINE_FORCE 0.6f

float y_player;                    /* player current y coord */
float character_velocity;          /* main character vertical velocity */

...

void handle_player()
{
    if (t % DAMPING == 0)          /* every damping cycle */
    {
        /* apply velocity */
        y_player += character_velocity;
        /* stop program if out of bounds */
        if (y_player >= HEIGHT || y_player < 0)
        {
            main_cycle = 0;
        }
        else
        {
            /* drawing player */
	    draw_pixel(MAINCHAR_X, y_player, 'V');
            /* physics */
            character_velocity += GRAVITY;
        }
    }
}

...

int main()
{
    signal(SIGINT, handler);       /* Registers handler() for SIGINT */
    main_cycle = 1;                /* Initializing runprogram flag */
    init_terminal();
    init_screen();
    t = 0;
    y_player = START_POSITION;
    character_velocity = -ENGINE_FORCE; /* start impulse */

    /* Main cycle */
    while(main_cycle)
    {
         draw_background();
	 handle_player();
         render_scene();
         t++;
    }

    restore_terminal();
    return 0;
}
									

Теперь для того, чтобы программа превратилась в игру - осталось осуществить возможность интерактивного ввода. Поменяем функцию-обработчик сигнала так, чтобы вызов обработчика увеличивал вертикальный импульс самолёта. Нужно помнить, что сигнал SIGINT не предназначен для пользовательского ввода, и переопределение его для целей, не связанных с выходом из программы является не более чем хаком. В общем случае так делать нельзя, мы же пользуемся этим хаком только до тех пор, как научимся пользоваться клавиатурным вводом в неблокирующем режиме.
Пока SIGINT переназначен, выход из программы будет осуществляться простым образом - как только аватар игрока потерпит крушение - вылетит за пределы экрана. Также можно пользоваться неперeопределяемым сигналом SIGQUIT - "Ctrl+\". При остановке процесса при передаче сигнала SIGQUIT создаётся core-файл - слепок ядра операционной системы на момент остановки программы.

void handler(int dummy) { character_velocity = -ENGINE_FORCE; }

В игру уже можно играть - цель игры не коснуться краёв экрана. Но чтобы добавить вариативности, нужно ввести, например, препятствия. Чтобы сделать колонны из # препятствие, нужно добавить проверку столкновений - систему коллизий. В простейшем случае можно проверять пиксель, в который будет рисоваться символ самолёта - нет ли там уже символа колонны. Тогда для проверки коллизий стоит выделить отдельную функцию - которая по заданным координатам проверяет, свободны ли они в игровом мире. Кроме пересечения с колоннами эта же функция (по смыслу!) может проверять и нахождение координат внутри вертикальных границ
Теперь функция handle_player() может вызывать check_collision(), а не заниматься проверкой самостоятельно.
(Заодно здесь я поменял символ самолёта, определив его константой)

#define PLAYER_SPRITE '>'
#define PARTICLE_SPRITE '.'

...

/** 0 - no collision, other value - collision **/
int check_collision(int x, int y)
{
    if (y < 0 || y >= HEIGHT)
    {
        /* 1 - out of bounds */
        return 1;
    } 
    char dot = check_pixel(x, y);
    if (dot == '#')
    {
        /* 2 - column collision */
        return 2;
    }
    return 0;
}

...

void draw_background()
{
    ...
        if (pixel == PLAYER_SPRITE)
        {
            pixel = PARTICLE_SPRITE;
        }
    ...
}

...

void handle_player()
{
    if (t % DAMPING == 0)
    {
        y_player += character_velocity;
	if (check_collision(MAINCHAR_X, y_player) != 0)
        {
            main_cycle = 0;
        }
        else
        {
	    draw_pixel(MAINCHAR_X, y_player, PLAYER_SPRITE);
            character_velocity += GRAVITY;
        }
    }
}

Тут же становится заметна проблема системы коллизий (с попыткой переложить реальные физические взаимодействия на любую модель всегда будут проблемы, невозможно абсолютно точно смоделировать реальность, но задача всегда заключается в наилучшем соответсвии моделируемой ситуации - и минимизации "багов" физического движка) Баг текущего физического движка заключается в том, что под определённым углом можно пролететь "сквозь" колонну - если колонна достаточно наклонена, а в текущей реализации колонны как раз находятся под именно таким углом. Происходит это потому, что текущая проверка ничего не знает про "колонны" и не отличает "перед колонной" от "за колонной", обнаруживая коллизию только при отрисовке самолётика на спрайте колонны. Для текущей ситуации исключить пролетание сквозь колонну можно просто проверяя, нет ли блока колонны одновременно на предыдущем за самолётом пикселе и пикселем под самолётом. Однако такой анализ работает только для текущего угла наклона колонн и, что даже более важно - работает только если самолёт движется достаточно "медленно" - отрисовывается на каждом пикселе своего пути, не перепрыгивая через несколько пикселей сразу. В общем случае, когда перемещения в каждом кадре больше, чем ширина препятствия, на коллизии с которым проводятся проверки, а в компьютреных играх это препятствие почти всегда - плоскость (то есть в случае, если за кадр проверяемое тело может сдвинуться на величину больше своего минимального линейного размера) - нужно было бы каждый раз проверять, существует ли прямой, минимальный путь между предыдущим нахождением объекта и его текущим нахождением, причём путь достаточной ширины. Эта проверка достаточно сложная, и выполнять её для всех объектов в современных, наполненных физическими взаимодействиями играх на каждом кадре очень дорого, и для этого в физическом движке применяется множество различных оптимизаций. Здесь же, принимая допущение о угле наклона колонн и скорости скролла экрана - проверка будет осуществляться для частного случая:

/** 0 - no collision, other value - collision **/
int check_collision(int x, int y)
{
    if (y < 0 || y >= HEIGHT)
    {
        /* 1 - out of bounds */
        return 1;
    } 
    char dot = check_pixel(x, y);
    if (dot == '#')
    {
        /* 2 - column collision */
        return 2;
    }
    char dot_behind = check_pixel(x - 1, y);
    char dot_under = check_pixel(x - 1, y);
    char dot_from = check_pixel(x - 1, y - 1);
    if ( dot_behind == '#'
      && dot_under == '#'
      &&(dot_from != PLAYER_SPRITE || dot_from != PARTICLE_SPRITE))
    {
        /* 3 - column fly-through */
        return 3;
    }

    return 0;
}

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

#define VOID_SIZE (HEIGHT / 3)

...

void draw_background()
{
    if (t % DAMPING == 0)          /* Every DAMPING'th frame */
    {
        /* scrolls all screen */
        for (int x = 0; x < WIDTH - 1; x++)
        {
            for (int y = 0; y < HEIGHT; y++)
            {
                char pixel = check_pixel(x + 1, y);
                if (pixel == PLAYER_SPRITE)
                {
                    pixel = PARTICLE_SPRITE;
                }
                draw_pixel(x, y, pixel);
            }
        }

        /* construct right edge */
        int block_y = (t / DAMPING) % (HEIGHT * GAP_WIDTH);
	if (block_y >= 0 && block_y <= HEIGHT)
        {
            /* count column's number */
            int void_y = ((t)/ DAMPING);
            void_y /= (HEIGHT * GAP_WIDTH);
            /* generate pseudo-random height from it */
            void_y *= 31337;
            void_y %= HEIGHT - VOID_SIZE;
            /* draw new column element */
            if (block_y < void_y
                || block_y > void_y + VOID_SIZE)
            {
		draw_pixel(WIDTH - 1, block_y, '#');
	    }
            /* and erase old one */
            draw_pixel(WIDTH - 1, block_y - 1, ' ');
        }
    }
}

Почти! Стоит добавить маленький штришок - счётчик очков:

int score;           /* achieved player score */

...

void handle_player()
{
    if (t % DAMPING == 0)
    {
	score++;
        y_player += character_velocity;
        if (check_collision(MAINCHAR_X, y_player) != 0)
        {
            main_cycle = 0;
        }
        else
        {
            draw_pixel(MAINCHAR_X, y_player, PLAYER_SPRITE);
            character_velocity += GRAVITY;
        }
    }
}

...

int main()
{
    signal(SIGINT, handler);       /* Registers handler() for SIGINT */
    main_cycle = 1;                /* Initializing runprogram flag */
    init_terminal();
    init_screen();
    t = 0;
    score = 0;
    character_velocity = -ENGINE_FORCE;
    y_player = START_POSITION;
    
    /* Main cycle */
    while(main_cycle)
    {
        draw_background();
        handle_player();
        render_scene();
        t++;
    }
    
    restore_terminal();
    printf("Game Over! Your score: %d\n", score);
    
    return 0;
}
Для вывода счёта можно было бы использовать и уже имеющуюся переменную t - однако при этом нужно помнить о возможности переполнения переменной, так как она растёт слишком быстро. Конечно, о её переполнении нужно думать и без "ведения счёта" - так как генерация колонн завязана на значение t, генерация во время смены граничных значений может преподнести сюрприз. Следует проверять поведение функций на граничных значениях, в "специальных случаях".

Самостоятельная работа (необязательно, но стоит проработать код ради того, чтобы позже были понятны преимущества ООП и структуризации проекта, использования систем сборки):
  • Следующий урок посвящён рефакторингу. Попробуйте переработать структуру программы по своему представлению - чтобы улучшить читаемость проекта, и облегчить возможность внесения доработок.
  • Сделайте расстояние между колоннами и разрыв в колонне нефиксированными, зависящими от значений генератора случайных значений.
  • Добавьте вывод полученных очков непосредственно в процессе игры, добавьте "жизни" - чтобы игра прекращалась не с первым же касанием препятствия.
  • Добавьте анимацию "взрыва" - чтобы при касании препятствия в месте касания отображалась какая-нибудь динамическая система частиц.