9. неделя

Bomber (Curses)

Простые ASCII-графические программы, созданный с помощью библиотеки _ncurses_

Тема лабораторной

До этого момента мы работали только со стандартным вводом\выводом, который не предоставлял возможности делать достаточно интерактивный и вразумительный вывод. На данном домашнем занятии вы научитесь работать с библиотекой ncurses, с помощью которой вы сможете создавать интерактивные приложения даже в среде текстовой консоли. Вы напишете простую игру Bomber.

Принципом игры Bomber является уничтожить ("разбомбить") город. Самолет летит над городом, снижаясь с каждым заходом вниз. Бомбардировщик может в одно время спустить только одну бомбу, которая в лучшем случае снесёт 4 этажа здания. Самолет может и стрелять перед собой, но максимально только на 5 позиций перед собой.

Благодаря данному задание вы будете готовы к выполнению задания Problem Set 6.

Цели

  1. Ознакомиться с ASCII-графической библиотекой ncurses .
  2. Написать игру Bomber .
  3. Подготовиться к выполнению Problem Set-а 6 .

Инструкции

Шаг 1: Curses Initialization

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

Задача 1.1

Сначала настроите среду для работы с библиотекой ncurses.

В файле ~/.bashrc измените следующий рядок:

export LDLIBS="-lm"

следующим образом:

export LDLIBS="-lm -lcurses"

Данное изменение обеспечит ссылание на библиотеку ncurses при сборке вашей программы.

Предупреждение

Для того, чтобы изменения вступили в силу, нужно перезайти в систему или ввести команду:

exec bash

Если изменения не проявились, вам следует проверить, не работаете ли вы на другой дистрибуции Linux, а точнее, не использует ваша система файл ~/.profile.

В случае, если вы работаете в другой среде разработки с OS Linux, установите себе сначала саму библиотеку ncurses (в случае, если она не установлена).

Для дистрибутивов, основанных на Debian:

sudo apt-get install libncurses5-dev libncurses5 ncurses-doc

Для дистрибутивов, основанных на RedHat-e:

sudo dnf install ncurses-devel

Затем сделайте соответствующие изменения в файле ~/.bashrc (см. выше).

Предупреждение

Если изменения не проявились, проверьте, не использует ли ваша система файла ~/.profile вместо ~/.bashrc.

Если вы до сих пор используете систему Windows, найдите решение сами. При в поиске вам может помочь данная страница.

Задача 1.2

Напишите программу bomber, в функции main() инициализируйте начало работы с библиотекой ncurses и перед завершением работы функции закончите работу с библиотекой.

Используйте вызовы следующих функций:

  • WINDOW *initscr(void)
    • инициализация работы библиотеки в программе
    • настраивает значения окна терминала, а также аллоцирует место основных окон (curscr, stdscr)
    • должна быть вызвана первой из функций библиотеки
  • int cbreak(void)
    • запрещает буферизацию
    • выключает возможность экстренного завершения программы (через Ctrl-C). Так, знаки становятся активными
  • int noecho(void)
    • обеспечивают, что знаки, считанные с помощью getch(), не будут выводиться на экран
  • int keypad(WINDOW *win, bool bf)
    • если bf TRUE, тогда пользователь может нажимать на работающие клавиши (например стрелочки) и функция wgetch() возвращает одно значение, представляющее клавишу, напр. KEY_LEFT
  • int curs_set(int visibility)
    • устанавливает видимость курсора (0 - невидимый, 1 - нормальный и 2 - "резковидимый"
  • int nodelay(WINDOW *win, bool bf)
    • способствует тому, что функция getch() будет вызываться без блокирования (сразу)
    • если нет ничего на входе, getch() возвращает ERR
    • если данный функционал заблокирован (bf FALSE), getch() будет ожидать, пока не будет нажата клавиша
  • int endwin(void)
    • заканчивает работу в режиме библиотеки ncurses и восстанавливает первоначальные настройки терминала

Комментарий

Для работы с библиотекой ncurses не забудьте подключить заголовочный файл curses.h!

#define _POSIX_C_SOURCE 200201L
#include <stdio.h>
#include <stdlib.h>
#include <curses.h>
#include <time.h>

int main(){
    srand(time(NULL));

    // initialize the library
    initscr();
    // set implicit modes
    cbreak();
    noecho();
    keypad(stdscr, TRUE);
    // invisible cursor, visibility of cursor (0,1,2)
    curs_set(FALSE);
    // getch() will be non-blocking
    nodelay(stdscr, TRUE);

    // your code goes here
    getchar();

    // end curses
    endwin();
    return EXIT_SUCCESS;
}

Задача 1.3

Скомпилируйте программу и проверьте её правильность.

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

Шаг 2: The City

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

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

Для того, чтобы вы смогли динамически реагировать на размер экрана и генерировать достаточное количество построек соответствующего размера, следует использовать специальные переменные COLS и LINES, которые предоставляет сама библиотека ncurses. Значение каждой из них следующее:

  • LINES - высота окна терминала
  • COLS - ширина окна терминала

При декларации массива в таком случае создайте массив столь большой, чтобы он не занимал весь экран, при этом с начала и с конца будет недоставать ровно 5 высотных значений.

При генерировании высоты отдельных элементов, удостоверьтесь, что высота строения не превышала половину высоты размера экрана.

int world[COLS - 2*5];
for(int i = 0; i < COLS - 2*5; i++){
    world[i] = rand() % LINES/2 + 1;
}

Задача 2.1

Выведите город на экран.

При работе с библиотекой ncurses также правдиво, что позиция [0,0] указывает на верхний левый угол экрана.

Для перемещения курсора на конкретную позицию экрана и последующий ввод на данную позицию можете использовать комбинацию вызовов следующих функций:

  • move(int y, int x) - переместит курсор на соответствующую позицию
  • printw() - выводит рядок на настоящую позицию курсора (использование данной функции совпадает с использованием вами уже известной printf())
  • mvprintw(int y, int x, const char *fmt, ...) - комбинация функций move() и printw(), переместит курсор на конкретную позицию и выпишет рядок
  • int refresh(void) - функция должна быть вызвана, иначе изменения не будут изображены в терминале

Комментарий

Для прочтения документации к каждой из функций вы можете использовать manual pages. Например, если вы хотите прочесть документацию к функции printw(), выполните команду:

man printw

Комментарий

При выводе можете использовать и замедление, в таком случае при выводе одного этажа одного строения выполнение вашей программы на миг остановите. Таким образом, возникнет эффект, при котором город будет вырисовываться постепенно. Для искусственного замедления можно использовать функцию nanosleep() из библиотеки time.h, использование которой демонстрирует следующий фрагмент кода:

// time delay 0.001s for drawing city
struct timespec ts = {
    .tv_sec = 0,                    // nr of secs
    .tv_nsec = 0.001 * 1000000000L  // nr of nanosecs
};

nanosleep(&ts, NULL);
// time delay 0.001s for drawing city
struct timespec ts = {
    .tv_sec = 0,                    // nr of secs
    .tv_nsec = 0.001 * 1000000000L  // nr of nanosecs
};

// draw world
for(int x = 0; x < COLS - 10; x++){
    for(int y = 0; y < world[x]; y++){
        mvprintw(LINES-y-1, x + 5, "#");
        refresh();
        nanosleep(&ts, NULL); // provides simple effect
    }
}

Задача 2.2

Проверьте правильность вашей реализации.

Если вы сделали всё верно, на экран терминала будут выведены здания, подобно следующему рисунку:

+-------------------------------------------------+
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|         #         #     #         #   #         |
|      #  # #       ##  ###         #   #  #      |
|      ## # # #     ##  ### ##      #   ## #      |
|      ## # # # # # ##  #########   #   ## #      |
|     ####### ########  #############   #####     |
|     #######################################     |
+-------------------------------------------------+

Шаг 3: The Plane

Главным героем сегодняшнего сценария является бомбардировщик, который всё время летит над городом, снижаясь с каждым поворотом. На данном этапе мы будем заниматься реализацией полёта.

Задача 3.1

Реализуйте полёт самолёта по всей ширине экрана.

Самолет может быть представлен как строка вида: " @=>-" или: " ^==-".

Один успешный полёт над городом изображен на следующей картинке:

+-------------------------------------------------+
|                                             ^==-|
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|         #         #     #         #   #         |
|      #  # #       ##  ###         #   #  #      |
|      ## # # #     ##  ### ##      #   ## #      |
|      ## # # # # # ##  #########   #   ## #      |
|     ####### ########  #############   #####     |
|     #######################################     |
+-------------------------------------------------+
// time delay 0.1s for plane
ts.tv_nsec = 0.1 * 1000000000L;

for(int x = 0; x < COLS; x++){
    mvprintw(0, x, " ^==-");
    refresh();
    nanosleep(&ts, NULL);
}

Задача 3.2

Измените свой код так, чтобы самолет пролетел с левого верхнего угла до правого нижнего (остановится).

Не забудьте о том, что верхний левый край мира имеет координаты [0,0].

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

+-------------------------------------------------+
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                             ^==-|
+-------------------------------------------------+
// time delay 0.1s for plane
ts.tv_nsec = 0.1 * 1000000000L;

// game loop
for(int y = 0; y < LINES; y++){
    for(int x = 0; x < (y==LINES-1 ? COLS-4 : COLS); x++){
        mvprintw(y, x, " ^==-");
        refresh();
        nanosleep(&ts, NULL);
    }
}

Задача 3.3

Сообщите игроку об успешной посадке надписью Well done!, помещённой в центр экрана.

Сообщение может выглядеть следующим образом:

+-------------------------------------------------+
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                   Well Done!                    |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                                 |
|                                             ^==-|
+-------------------------------------------------+
// well done
mvprintw(LINES/2, COLS/2 - 5, "Well Done!");
refresh();

Задача 3.4

Скомпилируйте программу и проверьте его правильность.

Если вы сделали всё правильно, бомбардировщик пролетит через весь экран, приземлится и на экран выведется сообщение Well Done!:

Задача 3.5

Измените настоящую реализацию так, что в случае, если самолет столкнётся со строением, на экран будет выведено сообщение Game over, при этом игра завершится.

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

Данный фрагменты кода поместите в игровой цикл:

// check collision with building
if (x > 0 && x < COLS-5 && y == LINES-world[x-1]) {
    mvprintw(LINES/2, COLS/2 - 5, "Game Over!");
    mvprintw(y, x, " ****");
    refresh();
    getchar();
    endwin();
    return EXIT_SUCCESS;
}

Шаг 4: Dropping the Bomb

На данном этапе мы займёмся программированием управлением с клавиатуры.

Задача 4.1

Игра должна завершить свою работу, если была нажата клавиша Q.

Считывание с клавиатуры в режиме библиотеки ncurses обеспечите вызовом функции getch(). Функция возвращает код считанной клавиши как значение типа int без того, что игра будет приостановлена.

Поскольку клавиша Q не будет единственной, которую будем для управления в игре использовать, используйте конструкцию switch.

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

Комментарий

Если у вас не установлена документация локально для библиотеки ncurses, её можно установить, выполнив команду:

sudo apt-get install ncurses-doc

Поместите данный фрагмент в код программы:

// handle user input
int input = getch();
switch(input){
    case 'q': case 'Q':{
        mvprintw(LINES/2, COLS/2 - 5, "Quit Game");
        refresh();
        getchar();
        endwin();
        return EXIT_SUCCESS;
        break;
    }
}

Задача 4.2

Сделайте так, чтобы при нажатии стрелочки вниз, самолёт выпустил бомбу.

При полёте самой бомбы руководствуйтесь следующим:

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

Код стрелочки вниз в библиотеке ncurses определён как KEY_DOWN.

Перед игровым циклом сначала инициализируйте переменные, касающиеся бомбы:

// bomb initialization
bool is_bomb_dropped = false;
int bomb_x;
int bomb_y;
int bomb_counter;

Затем, при нажатии стрелочки вниз инициализируйте свойства бомбы, для случая, если последняя уже не летит:

case KEY_DOWN:{
    if(is_bomb_dropped == false){
        is_bomb_dropped = true;
        bomb_x = x + 1;
        bomb_y = y;
        bomb_counter = 4;
    }
    break;
}

Наконец, измените игровой цикл та, чтобы в случае запуска бомбы она постепенно падала и уничтожала всё на своём пути (согласно правилам):

// update bomb
if(is_bomb_dropped == true){
    mvprintw(bomb_y, bomb_x, " ");
    bomb_y++;
    if(bomb_y == LINES || !bomb_counter){
        is_bomb_dropped = false;
    }
    else if(bomb_y == LINES - world[bomb_x-5]){
        world[bomb_x-5]--;
        mvprintw(bomb_y, bomb_x, "*");
        bomb_counter--;
    }
    else{
        mvprintw(bomb_y, bomb_x, "o");
    }
}

Задача 4.3

Скомпилируйте программу и проверьте его правильность.

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

Шаг 5: Fire in the Hole!

На данном этапе реализуйте возможность стрельбы.

Задача 5.1

В случае нажатия пробела (spacebar) должен быть открыт огонь с пулемёта впереди самолёта.

Игра приостановится. Пули будут долетать максимально на пять позиций перед самолётом в направлении его движения. Стрельба будет разносить все строения на расстоянии 5 знаков от бомбардировщика.

case ' ':{
    for(int i = 0; i < 5 && x+5+i < COLS-5; i++){
        // check collision with building
        if(y == LINES - world[x+i]){
            // this is collision
            mvprintw(y, x+i+5, "*");
            world[x+i]--;
        }
        else{
            // no collision here
            mvprintw(y, x+i+5, "~");
        }
        if(i > 0){
            // clear previous shot print (optional)
            mvprintw(y, x+i+4, " ");
        }
        refresh();
        nanosleep(&ts, NULL);
    }
    // clear last shot print (optional)
    if(x+5+4 < COLS-5){
        mvprintw(y, x+5+4, " ");
        refresh();
        nanosleep(&ts, NULL);
    }
    break;
}

Шаг 6: Game Info

Добавьте очки (score) и ограниченное количество пуль для бортового пулемёта.

Задача 6.1

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

// state line initialization
int score = 0;
int shots = 8;

// draw state line
mvprintw(0, 5, "SCORE     SHOTS");
mvprintw(1, 6, "%d", score);
mvprintw(1, 17, "%d", shots);
refresh();
nanosleep(&ts, NULL);

// update state line
mvprintw(1, 6, "%d", score);
mvprintw(1, 17, "%d", shots);

Задача 6.2

За каждый этаж, разрушенный бомбой добавляйте по 5 очков.

// update bomb
score += 5;

Задача 6.3

За каждый этаж, разрушенный из пулемёта добавляется по 15 очков.

// in case of ' '
score += 15;

Задача 6.4

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

// in case of ' '
if (!shots) {
    break;
}
else {
    shots--;
}

Задача 6.5

Скомпилируйте программу и проверьте её правильность.

Шаг 7: Colors

На данном этапе вы инициализируете работу с цветами и на некоторых местах их используете.

Задача 7.1

Добавьте в игру использование разных цветов.

Перед тем, как использовать разные цвета, их нужно инициализировать и настроить. Обычно настраиваются пары цветов (передний и задний планы). Каждая цветовая пара имеет свой номер, который вскоре будет использован в программе. Номер 0 не используется, начинайте с номером 1.

Инициализацию и настраивание цвета лучше поместить после инициализации библиотеки ncurses в главной функции main(), напр.:

int main(){
    // initialize the library
    initscr();
    // initialize colors
    start_color();
    // color sets
    init_pair(1, COLOR_YELLOW, COLOR_BLACK);
    init_pair(2, COLOR_GREEN, COLOR_BLACK);
    init_pair(3, COLOR_CYAN, COLOR_BLACK);

    // ...

    // end curses
    endwin();

    return 0;
}

После инициализации и настройки цвета его можно наконец использовать. Обычно сначала включается использование цветной пары и потом выключается (attron(), attroff()).

Использование цветов может выглядеть следующим образом:

attron(COLOR_PAIR(1));
printw("X");
attroff(COLOR_PAIR(1));

attron(COLOR_PAIR(2));
printw("O");
attroff(COLOR_PAIR(2));

refresh();

Шаг 8: Challenges

На данном этапе ознакомьтесь с другими вызовами библиотеки ncurses (упомянутыми в задании).

Задача 8.1

Освойте иные возможности использования библиотеки ncurses.

Скачайте код программы curses-example.c и выучите использование другого функционала:

  • void roll_text() - Постепенная прокрутка снизу экрана (течение времени).
  • void draw_logo() - Постепенно выводится предопределённое logo (течение времени).
  • void screensaver() - Текст гуляет по экрану, отбиваясь от стен.
  • void moving_arrow() - "Игрок" двигается по экрану в зависимости о того, какие кнопки жмёт пользователь. Движение осуществляется с помощью клавиш стрелочек.

Шаг 9: Problem Set 6: Curses

С данного момента можете себя считать готовыми к Problem Set 6.

Дополнительные задачи

Задача A.1

Добавьте в игру возможность приостановки (Pause), в случае, если пользователь нажмёт p или P.

Задача A.2

Добавьте в игру возможность изображения справки с помощью клавиш h или H.

Задача A.3

Добавьте в игру вводную страницу с меню.

Задача A.4

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

Задача A.5

После окончания игры выведите на экран контакты (credits). Эффект изображения можете выбрать сами (например в виде титров, как в фильме).

Дополнительные источники

  1. Public Domain Curses (for Windows users)
  2. Curses Example
  3. Writing Programs with NCURSES
  4. Introduction to the Unix Curses Library
  5. What does #define _POSIX_SOURCE mean?
  6. Функция initscr(): docs.oracle.com - Screen initialization function
  7. Функция cbreak(): docs.oracle.com - Set input mode controls
  8. Функция noecho(): docs.oracle.com - Disable terminal echo
  9. Функция keypad(): docs.oracle.com - Enable/disable keypad handling
  10. Функция curs_set(): docs.oracle.com - Set visibility of cursor
  11. Функция nodelay(): docs.oracle.com - Set blocking or non-blocking read
  12. Функция endwin(): docs.oracle.com - Restore initial terminal environment
  13. Функция wgetch(): docs.oracle.com - Get a single-byte character from the terminal
  14. Переменная LINES: docs.oracle.com - Number of lines on terminal screen
  15. Переменная COLS: docs.oracle.com - Number of columns on terminal screen
  16. Функция move(): docs.oracle.com - Move cursor in window
  17. Функция printw(): docs.oracle.com - Print formatted output window
  18. Функция mvprintw(): docs.oracle.com - Print formatted output window
  19. Функция refresh(): docs.oracle.com - Refresh windows and lines
  20. Функция nanosleep(): qnx.com - Suspend a thread until a timeout or signal occurs
  21. Функция getch(): docs.oracle.com - Get a single-byte character from the terminal
  22. Команда switch: cppreference.com - Transfers control to one of the several statements, depending on the value of a condition
  23. Функция start_color(): docs.oracle.com - Manipulate color information
  24. Функция init_pair(): docs.oracle.com - Manipulate color information
  25. Функция attron(): docs.oracle.com - Change foreground window attributes
  26. Функция attroff(): docs.oracle.com - Change foreground window attributes
  27. Команда reset: tutorialspoint.com - Terminal initialization

Видео