[Hello, Habr!] Змейка в консоли. Разбираемся с с make и gcc

в 11:43, , рубрики: C, makefile, игра в терминале

Всем привет! Это моя первая публикация на Хабре и я решил посвятить её тому, как я писал змейку в консоли (да коряво, но всё же).

Итак, зачем я её вообще затеял? Я просто хотел разобраться как работать с make и gcc и для примера решил написать змейку в консоли ¯_(ツ)_/¯

Я написал самый обыкновенный makefile, в подробности его устройства вникать не будем. Просто покажу код:

Makefile
C = gcc 
DEBUGER = gdb
ld = gcc
ld_FLAGS = -lgcc
CFLAGS = -Wall -lmsvcrt

SRC_D = src
OBJ_D = obj
BIN_D = bin

SRC_C = $(wildcard $(SRC_D)/*.c)
OBJ_C = $(patsubst $(SRC_D)/%.c, $(OBJ_D)/%.o, $(SRC_C))
TARGET1 = FirstGCC

all : $(TARGET1)

$(TARGET1): $(OBJ_C)
	$(ld) $(ld_FLAGS) -o $@ $<

$(OBJ_C): $(SRC_C)
	$(C) $(CFLAGS) -c $< -o $@

DEBUG: $(TARGET1)
	$(DEBUGER) $(TARGET1)
	cls
mkdirs:
	mkdir $(OBJ_D)
	mkdir $(BIN_D)


Небольшая подготовка

С чего начинается C? С функции main. В ней я сначала получил дескриптор выводного потока консоли (ой как понадобиться в дальнейшем), а так же сделал курсор в консоли невидимым:

HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
if (hConsole == INVALID_HANDLE_VALUE)
{
  printf("ERROR! Console handle is invalid!");
  return 1;
}
CONSOLE_CURSOR_INFO cInfo;
GetConsoleCursorInfo(hConsole, &cInfo);
cInfo.bVisible = FALSE;
SetConsoleCursorInfo(hConsole, &cInfo);

Надобно поздороваться с пользователем и дать ему время морально настроиться:

printf("Hello world!rn"
       "Press any key to continue...");
getch();

Основной цикл

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

P.S. змейка управляется кнопками W S A D

srand(time(0)); // просто так да
while (1)
{
  DrawNCart(hConsole);
  snakeV = getch();
  if (snakeV == 'q' || snakeV == 'Q')
    isGameEnd = TRUE;
  if (isGameEnd)
    break;
}

DrawNCart - рисует карту и занимается логикой игры
snakeV - сохраняется действие пользователя, которое обрабатывается в следующем кадре
isGameEnd - продолжается игра или нет (false/true)

Рассмотрим саму логику игры:

DrawNCart
#define W 50 // ширина поля игры
#define H 20 // высота поля игры
void DrawNCart(HANDLE hConsole)
{
    system("cls"); // очищаем экран
    COORD cPosition = {0, 0}; // позиция курсора (X - столбец; Y - строка)
    for (short i = 0; i < W; i++) // рисуем верхнюю границу
    {
        cPosition.X = i;
        SetConsoleCursorPosition(hConsole, cPosition);
        WriteConsoleA(hConsole, "#", 1, 0, 0);
    }
    for (short i = 0; i < H; i++) // рисуем боковые границы
    {
        cPosition.X = 0;
        cPosition.Y = i;
        SetConsoleCursorPosition(hConsole, cPosition);
        WriteConsoleA(hConsole, "#", 1, 0, 0);

        cPosition.X = W;
        cPosition.Y = i;
        SetConsoleCursorPosition(hConsole, cPosition);
        WriteConsoleA(hConsole, "#", 1, 0, 0);
    }
    cPosition.Y = H;
    for (short i = 0; i < W; i++) // рисуем нижнюю  границу
    {
        cPosition.X = i;
        SetConsoleCursorPosition(hConsole, cPosition);
        WriteConsoleA(hConsole, "#", 1, 0, 0);
    }
    HandleSnake(hConsole); // рисуем змею
    SetFruct(hConsole); // раскидываем фрукты
    SetConsoleCursorPosition(hConsole, (COORD){1, H+1}); // выведем счёт
    printf("Score %d", Score);
}

В этой функции мы в первую очередь очищаем экран от предыдущих кадров. Позицией вывода символов управляем с помощью SetConsoleCursorPosition, символы выводим с помощью WriteConsoleA и printf. Наша карта шириной 50 символов (за вычетом границ) и высотой 20 символов (опять же, за вычетом границ).

Нарисуем змейку? Для этого напишем функцию HandleSnake:

HandleSnake
if (Snake == 0)
{
  Snake = (COORD *)malloc(sizeof(COORD) * snakeSize);
  if (!Snake)
  {
    printf("Not have free memory for game!rn");
    exit(-1);
  }
  Snake[0] = (COORD){25, 10};
}

TransformSnake();
for (int i = 0; i < snakeSize; i++)
{
  SetConsoleCursorPosition(hConsole, Snake[i]);
  if (i == 0)
    WriteConsoleA(hConsole, "9", 1, 0, 0);
  else
    WriteConsoleA(hConsole, "8", 1, 0, 0);
}

Змейка представляет собой массив позиций каждой её части, при чём первый элемент массива - голова змеи. Так, если массив пустой (змеи не существует) создаётся массив с одним элементом (только голова змеи). При запуске игры snakeSize равен 1. Голова змеи располагается ровно в центре карты. Голову змеи обозначаем циферкой 9, а тело и хвост циферками 8.
TransformSnake - перемещает змейку в направлении выбранном пользователем и, в случае, если она съела яблоко, увеличивает её длину на 1.

Устройство этой функции предельно просто, хотя кода много:
пользователь нажал W - змея двигается вперёд (вверх), пользователь нажал S - змейка двигается назад (вниз) и т.д.

switch (snakeV)
{
  case 'W':
  case 'w':
  {
    if (snakeLV == 'S' || snakeLV == 's') // защита от дурака
    {
      snakeV = snakeLV; // змея продолжает двигаться в прежнем направлении
      TransformSnake();
      break;
    }
    for (int i = snakeSize - 1; i >= 0; i--) // двигает каждый элемент змейки в нужном направлении
    {
      if (i == 0)
      {
        Snake[i].Y -= 1;
      }
      else
      {
        Snake[i].Y = Snake[i - 1].Y;
        Snake[i].X = Snake[i - 1].X;
      }
    }
    snakeLV = snakeV;
  }break;
    // ... 
}

Змея укусила себя или забор? Игра окончена

if (Snake[0].X == W || Snake[0].X == 0)
{
  isGameEnd = TRUE; // укусила боковые границы
}
if (Snake[0].Y == H || Snake[0].Y == 0) 
{
  isGameEnd = TRUE; // укусила нижнюю или верхнюю границы
}
for (int i = 1; i < snakeSize; i++) 
{
  if (Snake[0].X == Snake[i].X && Snake[0].Y == Snake[i].Y)
    isGameEnd = TRUE; // укусила себя
}
Раскидаем яблоки - накормим змею!
void SetFruct(HANDLE hConsole)
{
    if (fructPos.X == 0 && fructPos.Y == 0) // Если фрукт не существует или проглочен
    {
        while (1)
        {
            fructPos.X = RandomRange(1, W - 1);
            fructPos.Y = RandomRange(1, H - 1);
            BOOL isGoodCoord = TRUE;
            for (int i = 0; i < snakeSize; i++)
            {
                if (Snake[i].X == fructPos.X && Snake[i].X == fructPos.Y)
                    isGoodCoord = FALSE;
            }
            if (isGoodCoord)
                break;
        }
    }
    SetConsoleCursorPosition(hConsole, fructPos);
    WriteConsoleA(hConsole, "0", 1, 0, 0);
}
int RandomRange(int minN, int maxN)
{
    return (rand() % (maxN - minN)) + minN; // "золотой стандарт"
}

fructPos - переменная типа COORD. При запуске игры или при съедении обозначается как {0,0}. На этих координатах фрукт существовать не может (верхний левый угол границы поля игры)
В цикле фрукту подбираются такая позиция, чтобы он не оказался внутри змеи, а затем до тех пор, пока не будет проглочен фрукт отображается на этой позиции. Фрукт обозначен циферкой 0

Ну съела змея фрукт, что дальше? А дальше нам нужно удлинить её на один за каждый фрукт:

Узнаём съела ли змея фрукт (TransformSnake )
if (Snake[0].X == fructPos.X && Snake[0].Y == fructPos.Y) // голова змеи = позиция фрукта
{
  fructPos = (COORD){0, 0}; // фрукт - съеден
  Score += 1; // счёт = счёт + 1
  AddSnakeLength(); // сейчас раскрою, как её удлиню
}

Ну в целом удлинить змею дело не затейливое. Достаточна гирька Понадобиться всего лишь написать функцию на 48 строк:

AddSnakeLength
void AddSnakeLength()
{
    snakeSize++;
    COORD *nSnake = (COORD *)realloc(Snake, snakeSize * sizeof(COORD));
    if (nSnake == NULL)
    {
        isGameEnd = TRUE;
        return;
    }
    Snake = nSnake;
    if (snakeSize == 2)
    {
        switch (snakeV)
        {
        case 'W':
        case 'w':
        {
            Snake[1].X = Snake[0].X;
            Snake[1].Y = Snake[0].Y + 1;
        }
        break;
        case 'S':
        case 's':
        {
            Snake[1].X = Snake[0].X;
            Snake[1].Y = Snake[0].Y - 1;
        }
        break;
        case 'A':
        case 'a':
        {
            Snake[1].X = Snake[0].X + 1;
            Snake[1].Y = Snake[0].Y;
        }
        break;
        case 'D':
        case 'd':
        {
            Snake[1].X = Snake[0].X - 1;
            Snake[1].Y = Snake[0].Y;
        }
        break;
        }
    }
    else if (snakeSize > 2)
    {
        unsigned int XVector = Snake[snakeSize - 3].X - Snake[snakeSize - 2].X;
        unsigned int YVector = Snake[snakeSize - 3].Y - Snake[snakeSize - 2].Y;
        Snake[snakeSize - 1] = (COORD){Snake[snakeSize - 2].X + XVector, Snake[snakeSize - 2].Y + YVector};
    }
}

Первым делом увеличим счётчик длины змеи snakeSize, затем переопределим массив элементов нашей змеи Snake увеличив его на 1.
В одиннадцатой строчке проверяем змею на "новорождённую". Если да, то удлиняем в необходимом направлении, если нет, то вычисляем направление хвоста змеи и удлиняем в этом направлении:

unsigned int XVector = Snake[snakeSize - 3].X - Snake[snakeSize - 2].X; // направление хвоста по X
unsigned int YVector = Snake[snakeSize - 3].Y - Snake[snakeSize - 2].Y; // направление хвоста по Y
Snake[snakeSize - 1] = (COORD){Snake[snakeSize - 2].X + XVector, Snake[snakeSize - 2].Y + YVector};

Ах да, чуть не забыл освободить ресурсы при выходе из игры:

// в конце игры... int main...
free(Snake);
system("cls");
printf("Game is over!rn Your score: %d", Score);
GetConsoleCursorInfo(hConsole, &cInfo);
cInfo.bVisible = FALSE;
SetConsoleCursorInfo(hConsole, &cInfo);
getch();

Прошу сильно не бить! Это моя первая статья не то, что на Habr, а во всём интернете. Буду рад конструктивной критике. Всем до новых встреч!

Автор: Procher

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js