Урок 6
Программа готова, компилируется без предупреждений и работает как ожидается.Но нужно помнить, что результатом работы программиста является не только работающая программа, но и текст программы. Текст программы написан на человекочитаемом языке и предназначен в первую очередь для того, чтобы быть понятным человеку. Если бы программы, которые пишут программисты, и языки программирования вообще не ориентировались на понятность именно человеку, мы бы просто писали сразу в машинных кодах (или байт-кодах, в случае виртуальных машин). Чтобы программа была более понятной и, как следствие, в ней легче было бы искать и исправлять ошибки а так же вносить дополнения и улучшения, программа должна быть хорошо структурирована, все части программы, отвечающие за определённый функционал должны быть сгруппированы и по возможности должны быть как можно сильнее изолированы от остальной программы. При написании программы стоит помнить, что функции должны выполнять одну конкретную цель, одной и та же функция не должна одновременно считать прошедшее время игры и вести подсчёт набранных очков, к примеру - потому что глядя на функцию должно быть сразу понятно, что она делает, для чего она и на что она может влиять (а на что - не может). Всё это нужно для облегчения понимания программы. Мы не можем ожидать, что пользователь нашей игры не захочет заглянуть в наш код - посмотреть, как он устроен и, возможно, дополнить, попытаться улучшить игру. Даже если не думать об удобстве других пользователей программы, нужно понимать что возвращаться к своему собственному коду спустя какое-то время - это, зачастую, то же самое, что в первый раз открывать чужой код. Поэтому всегда нужно стремиться писать как можно аккуратней, структурировать программу и поддерживать общепринятые практики написания кода. А код в текущем состоянии не очень-то подходит для погружение в него несведующим человеком - он представляет собой одну большую простыню текста, никак практически не структурированную, и с первого взгляда тяжело понять что есть что. Даже функция main(), с которой можно бы начать распутывать клубок программы, находится в самом низу, а не в начале программы, как этого можно было бы ожидать. Так же в программе сейчас широко используются глобальные переменные - переменные, объявленные в самом начале программы и используемые на всём протяжении её. Причём чтобы понять, где конкретно такая переменная может измениться, нужно просмотреть весь текст программы. Написанный подобным образом код называется "спагетти-кодом", так как пронизывающие насквозь глобальные переменные переплетаются в коде непредсказуемым образом, и с увеличением объёма программы "спагетти-код" становится настолько запутанным, что исчезают какие-бы то ни было возможности отследить в нём потенциальные (и реальные!) ошибки, а добавление нового функционала настолько сложно, что каждое изменение затрагивает каждый уголок программы, множество различных функций - и поэтому скорее всего вносит множество непредсказуемых ошибок.
Для уменьшения вышеобозначенных проблем, и для увеличения структурированности программы, стоит разнести несущие разный смысл функции в итоговой программе в разные единицы трансляции (т.е. разные .c): главный цикл (вместе с функцией main()) отдельно, чтобы легко можно было найти точку входа и, собственно, увидеть основные процессы, происходящие в главном цикле - не погружаясь в частности. Механизм обработки пользовательского ввода - отдельно, отдельно вывод графики (в том числе и для того, чтобы позже можно было поменять механизм вывода графики, не затрагивая остальную программу), и отдельно - обработку состояния, поведения игрового мира.
Разделение функций приносит пользу и в оптимизации времени сборки проета. Пока программа совсем небольшая, и состоит из небольшого количества кода это не актуально, а компиляция завершается практически мгновенно - но с ростом проекта перекомпиляция занимает всё больше и болшье времени. В языке C файл с расширением .c рассматривается как "единица трансляции", каждый c-файл компилируется в объектный файл (на основе файла .c компилятором создаётся файл с расширением .o), и итоговая программа линкуется из всех полученных объектных файлов - т.е. из одной или более единиц трансляции собирается результирующий исполняемый файл). Перекомпилировать все единицы трансляции программы, если изменения внесены только в один файл - неэффективно. Намного эффективнее было бы не удалять объектные файлы, и оставлять их (пока что наш компилятор удалял промежуточный объектный файл, как только заканчивал линковать результирующую программу). Тогда позже - по необходимости - можно перекомпилировать из исходного кода только те файлы, в которые были внесены изменения, а прочие файлы не трогать, воспользовавшись уже ранее скомпилированными объектными файлами.
#include <stdio.h>
#include <signal.h>
#define WIDTH 80
#define HEIGHT 24
#define MAINCHAR_X 10
#define START_POSITION_Y HEIGHT / 2;
#define ENGINE_FORCE 0.6f
int main_cycle; /* stop program if 0 */
int score; /* achieved player score */
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_Y;
/* Main cycle */
while(main_cycle)
{
draw_background();
handle_player();
render_scene();
t++;
}
restore_terminal();
printf("Game Over! Your score: %d\n", score);
return 0;
}
main()
из предудыщей программы - возникнет очевидная проблема: в main()
используются функции, которых в тексте единицы трансляции нет. Тогда, при попытке скомпилировать функцию main()
компилятор в месте вызова такой функции (в этом случае - сразу в начале, в месте вызова signal()
) - компилятор не будет знать что делать, так как ему нужно генерировать в этом месте какой-то код, а какой - он не знает, потому что кроме как "нужно передать функции signal()
переменную handler" в коде про handler ничего нет: ни как он устроен, ни где его взять. Текст функции handler()
, на основе которого компилятор раньше понимал, что речь идёт о указателе на функцию, был в прошлой версии программы, но в написанном подобным образом файле main.c его нет. Такой текст называется определением функции. Определение функции начинается с типа возвращаемого значения, потом указывается имя функции и потом, в круглых скобках - параметры функций (с типами параметров), если они нужны. Далее открывается фигурная скобка - и, собственно, внутри фигурных скобок пишется код тела функции:
printf()
, например), или, что даже более наглядно, в таком случае - когда существует две функции, каждая из которых использует другую:
bar()
компилятор ещё не знает о ней - она будет определена ниже. Но определить bar()
до определения функции foo()
так же не разрешит проблему - в функции используется foo()
, а в таком случае компилятор о ней ещё не будет знать.
Для разрешения таких ситуаций в Си реализован механизм объявлений. Объявление функции выглядит практически так же, как и определение функции, но без самого тела функции:
Пользуясь возможностью объявить функцию до определения, можно теперь реализовать пример со взаимным использованием функциями друг друга так, чтобы они могли скомпилироваться:
boo()
отдельно, так как она всё равно будет известна компилятору в момент первого использования.
Теперь мы можем написать файл main.c так, что он скомпилируется:
#include <stdio.h> #include <signal.h> void init_terminal(); void init_screen(); void draw_background(); void handle_player(); void render_scene(); void restore_terminal(); void handler(int dummy); #define WIDTH 80 #define HEIGHT 24 #define MAINCHAR_X 10 #define START_POSITION_Y HEIGHT / 2; #define ENGINE_FORCE 0.6f int main_cycle; /* stop program if 0 */ int score; /* achieved player score */ float character_velocity; int y_player; int t; 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_Y; /* Main cycle */ while(main_cycle) { draw_background(); handle_player(); render_scene(); t++; } restore_terminal(); printf("Game Over! Your score: %d\n", score); return 0; }
При этом в main()
всё ещё остаются не связанные непосредственно с главным циклом переменные и константы: WIDTH, HEIGHT, START_POSITION_Y, score, character_velocity, y_player. printf() в конце программы и signal()
в начале не носят какого-то общего смысла, а выполняют непосредственную работу, в частности они должны будут быть заменены когда изменится способ взаимодействия с пользователем, поэтому они не должны быть в main()
- изменения в main()
должны появлятся только когда в программе меняется архитектура глобально, на высоком уровне. Тем не менее, пока что можно позволить себе оставить переменную t - она является для программы, по сути, временем (хоть и не реальным временем, а количеством тактов процессора, которые программа уже отработала - другими словами временем жизни программы). Всегда стоит помнить, что если вы решили вставить в свою программу глобальную переменную, то скорее всего в дизайне вашей программы что-то не так, и стоит это что-то сделать иначе, без использования глобальных переменных. Однако время - в нашем мире это и есть глобальная переменная, одна на весь мир (ну без учёта релятивистских эффектов, конечно), и любое время, запрошенное программой от системных часов, по сути будет являтся значением глобальной переменной, которая просто хранится не как обычно, в памяти компьютера, а хранится неявно - в системных часах.
Для того, чтобы полностью избавить main.c от лишних для него значений и функций, напишем сначала прочие файлы проекта - которые main.c сможет использовать.
#include <stdio.h>
#define WIDTH 80
#define HEIGHT 24
char screen[(WIDTH + 1) * HEIGHT]; /* One extra char for linebreak */
int get_width()
{
return WIDTH;
}
int get_height()
{
return HEIGHT;
}
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 >= 0 && y >= 0 && x < WIDTH && y < HEIGHT)
{
screen[(WIDTH + 1) * y + x] = p;
}
}
char check_pixel(int x, int y)
{
char result = '\0';
if ( x >= 0 && y >= 0 && x < WIDTH && y < HEIGHT)
{
result = screen[(WIDTH + 1) * y + x];
}
return result;
}
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 render_scene()
{
printf("\033[H"); /* Set cursor to upper-left position */
printf("%s", screen); /* Draws "screen" */
}
void gameover_score_to_cout(int score)
{
printf("Game Over! Your score: %d\n", score);
}
void init_graphics()
{
init_terminal();
init_screen();
}
void finalize_graphics()
{
restore_terminal();
}
Кроме всех связанных с выводом графики функций, которые уже были реализованы, появилось несколько новых: gameover_score_to_cout()
, init_graphics()
и finalize_graphics()
- для того, чтобы забрать заботы о подготовке и завершении работы с графикой у main()
, и две функции-геттера get_width()
и get_height()
. Так как размеры экрана в общем случае не обязаны быть фиксированы - и могли бы меняться при изменении окна игры или при изменении размера пикселя, отвечать за размеры экрана должен именно механизм вывода изображения, а пользующиеся им функции должны просто иметь возможность узнать размер и использовать эту информацию. В нашем же случае просто неудобно тянуть константы препроцессора во все файлы, где используется взаимодействие с устройством вывода.
Отвечающий за пользовательский ввод файл input.c содержит функцию signal_handler()
, которая регистрируется для вызова по прихождению сигнала SIGINT. При этом функция не непосредственно увеличивает импульс самолётика, а вызывает функцию, которую прежде нужно зарегестрировать уже в нашем коде - и уже эта, регистрируемая функция выполняет требуемое действие - увеличивает ли импульс, или осуществляет любое другое требуемое действие. void (*p_handler)()
- так определяется указатель на функцию, переменная, которая содержит этот указатель называется p_handler. При этом сам указатель в момент объявления указывает в никуда, переменная неинициализирована и содержит случайное значение:
#include <signal.h>
void (*p_handler)();
void signal_handler(int dummy)
{
(*p_handler)();
}
void init_player_input(void (*f)())
{
p_handler = f;
signal(SIGINT, signal_handler); /* Registers function for SIGINT */
}
signal()
передавалась непосредственно регистрируемая функция. Необходимость в отдельном указателе на функцию возникла только ради избавления от накладываемых механизмом сигналов требований на регистрируемую функцию - регистрируемая функция должна принимать аргумент типа int. Можно было бы реализовать файл signal.h со старым подходом, и файл бы выглядел проще:
#include <signal.h>
void init_player_input(void (*f)(int))
{
signal(SIGINT, f); /* Registers function for SIGINT */
}
init_player_input()
, т.е. которую он зарегистрирует, и в дальнейшем вызывать эту зарегистрированную функцию по необходимости - в нашем случае, просто вызывать её по наступлению сигнала SIGINT.
Реализованный подход с запоминанием пришедшей извне функцией, и использование этой функции по наступлению какого-либо события, можно было бы расширить на несколько "подписчиков". Получение сигнала SIGINT - это событие, и мы регистрируем функцию, переданную в функцию init_player_input()
для обработки этого события. В общем случае можно было бы позволить нескольким функциям одновременно зарегистрироваться ("подписаться") на вызов по наступлению события. Такой подход называется паттерн "Listener" или, что почти то же самое, паттерн "Observer" (отличие "Observer" от "Listener" в том, что в "Observer" подписчики так же могут и генерирвать события самостоятельно, "Listener" реализует только возможность получения событий извне). Чтобы для вызова каких-либо функций не приходилось постоянно в цикле проверять, произошло событие или нет (например, была ли нажата кнопка на клавиатуре) - можно просто подписать эти функции на событие, и вызывать их в момент, когда это событие произошло. Шаблон Observer/Listener позволяет в некоторых случаях в программе полностью избавится от главного цикла, и работать только в момент наступления события, обрабатывая их. Компьютерные игры реального времени должны постоянно работать, вне зависимости играет человек в игру или просто смотрит на картинку, но вот шахматы, к примеру, могли бы быть написаны в парадигме событийно-ориентированного программирования (event-driven programming) - чтобы приложение не расходовало впустую ресурсы компьютера, ожидая хода игрока. Тем не менее части программы, как в нашем случае - пользовательский ввод, проще и правильнее всего реализовывать событийно-ориентированно, и в игровых движках есть отдельный механизм - система событий - который отслеживает все события от игрока и, зачастую, может также отслеживать и внутриигровые события и события движка игры.
Функции (а так же константы и переменные), связанные с реализацией игрового мира размещены в файле world.c
#define DAMPING 1000
#define GAP_WIDTH 3
#define START_POSITION_X 0.1f
#define GRAVITY 0.02f
#define START_POSITION_Y 0.5f;
#define ENGINE_FORCE 0.6f
#define PLAYER_SPRITE '>'
#define PARTICLE_SPRITE '.'
#define VOID_SIZE 0.3f
void gameover_score_to_cout(int score);
int void_size; /* column gap in pixels */
float x_player; /* player current x coord */
float y_player; /* player current y coord */
float character_velocity; /* main character vertical velocity */
int score; /* achieved player score */
extern int t;
extern int main_cycle;
int get_width();
int get_height();
int check_pixel(int x, int y);
int draw_pixel(int x, int y, char pixel);
int check_collision(int x, int y);
void init_player_input(void (*f)());
void add_force()
{
character_velocity = -ENGINE_FORCE;
}
void init_world()
{
int height = get_height();
int width = get_width();
init_player_input(add_force);
score = 0;
character_velocity = 0.0f;
add_force();
void_size = height * VOID_SIZE;
y_player = height * START_POSITION_Y;
x_player = width * START_POSITION_X;
}
void draw_background()
{
int screen_width = get_width();
int screen_height = get_height();
if (t % DAMPING == 0) /* Every DAMPING'th frame */
{
/* scrolls all screen */
for (int x = 0; x < screen_width - 1; x++)
{
for (int y = 0; y < screen_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) % (screen_height * GAP_WIDTH);
if (block_y >= 0 && block_y <= screen_height)
{
/* count column's number */
int void_y = t / DAMPING;
void_y /= screen_height * GAP_WIDTH;
/* generate pseudo-random height from it */
void_y *= 31337;
void_y %= screen_height - void_size;
/* draw new column element */
if (block_y < void_y
|| block_y > void_y + void_size)
{
draw_pixel(screen_width - 1, block_y, '#');
}
/* and erase old one */
draw_pixel(screen_width - 1, block_y - 1, ' ');
}
}
}
/** 0 - no collision, other value - collision **/
int check_collision(int x, int y)
{
int screen_height = get_height();
if (y < 0 || y >= screen_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;
}
void handle_player()
{
if (t % DAMPING == 0)
{
score++;
y_player += character_velocity;
if (check_collision(x_player, y_player) != 0)
{
main_cycle = 0;
}
else
{
draw_pixel(x_player, y_player, PLAYER_SPRITE);
character_velocity += GRAVITY;
}
}
}
void finalize_world()
{
gameover_score_to_cout(score);
}
Ключевое слово extern при объявлении переменных t и main_cycle служит для того, чтобы указать, что эти переменные являются глобальными. Дело в том, что переменные, как и функции, объявляются и определяются. Объявление функции и переменной - это указание компилятору, что такая функция либо переменная вообще существует, и после объявления компилятор, встретив набор символов, соответствующий объявлению, знает с чем он столкнулся (и, соответственно, что с этим можно сделать). Определение же - это сопоставление имени (имени переменной или имени функции) места в памяти. В случае функции в этой памяти будет размещён код функции, для переменной - просто несколько байт, в которых значение этой переменной будет храниться. Для функции можно написать объявление, не указывая её определение - тогда компилятор будет знать, что функция определа где-то ещё. Переменную тоже можно объявить, не определяя - для этого как раз и используется ключевое слово extern. Без extern компилятор может выделит память для переменной, но extern говорит компилятору, что хотя такая переменная и нужна в программе, но она уже существует где-то в другом месте (в другой единице трансляции, в нашем случае - в main.c). Линковщик потом найдёт, где определена эта переменная, и прилинкует её адрес из другой единицы трансляции в места, где к переменной происходит обращение. Указанные же без ключевого слова extern переменные чаще всего объявляются и определяются - чаще всего. Но вообще-то в таком случае компилятор сам решает, определять или нет (выделять или нет под переменную место в единице трансляции) переменную - и его решение иногда может удивить. Поэтому, если предполагается обращение к переменной из других единиц трансляции - то нужно переменную определить явным образом. Чтобы явно определить переменную, нужно в момент объявления присвоить ей значение:
...
int main_cycle = 0;
int t = 0;
...
Процессы по инициализации игрового мира теперь происходят не в функции main()
, а в специально сгруппировавшей в себе все подготовительные действия init_world()
. В том числе происходит регистрация функции, "подкидывающей" самолёт вверх. Функция add_force()
теперь регистрируется не непосредственно библиотечной функцией signal()
, а нашей собственной init_player_input()
- и поэтому больше не должна иметь параметр-заглушку типа int только для соостветствия требованиям механизма сигналов.
Константы, отвечающие за высоту и ширину экрана не определены в файле world.c - и поэтому вместо них в функциях теперь используются внутренние переменные этих функций, принимающие значения высоты и ширины от функций get_height()
и get_width()
.
Функция finalize_world()
занимается тем, что выводит последнее сообщение с надписью "Game Over" и счётом игры, но нужно помнить что вызывать её нужно будет, когда экран будет приведён в исходное состояние, иначе набранный счёт никто не увидит - когда буфер сменится, надпись останется в спрятанном буфере.
С использованием всех реализованных выше функций, текст файла с главным циклом теперь может выглядеть вот так:
int main_cycle = 0; /* stop program if 0 */ int t = 0; void init_graphics(); void init_world(); void draw_background(); void handle_player(); void render_scene(); void finalize_graphics(); void finalize_world(); int main() { init_graphics(); init_world(); t = 0; main_cycle = 1; /* Initializing runprogram flag */ /* Main cycle */ while(main_cycle) { draw_background(); handle_player(); render_scene(); t++; } finalize_graphics(); finalize_world(); return 0; }
main()
не видны (и не должны быть видны!)
Функция main()
теперь выполняет необходимую инициализацию (в основном используя специальные инициализирующие функции), входит в главный цикл - а выйдя из главного цикла выполняет завершающие действия (опять же, с помощью функций-финализаторов), и на этом работа программы штатно завершается.
Компилируется программа, состоящая из нескольких ".c"-файлов, почти так же, как и одиночный ".c"-файл. В простейшем случае достаточно только передать компилятору имена всех компилируемых файлов, компилятор самостоятельно по очереди скомпилирует их, а потом вызовет линковщик и слинкует результаты своей работы в итоговую программу:
gcc main.c input.c world.c graphics.c
gcc -Wall -g main.c input.c world.c graphics.c -o horizon.game
Самостоятельная работа:
- Единица трансляции. Объектный файл. Линковка.
- Указатели на функции. Синонимы типов, typedef.