На главную

Змейка

Lighting/Colors

Создадим мини-игру змейка.

Для хранения змейки воспользуемся однонаправленным связанным списком. Объявим класс SnakeNode, который будет хранить координаты текущего элемента змейки и указатель на следующий элемент. Использование односвязанного списка удобно по ряду причин которые будут рассмотрены ниже.

Односвязанный список хранит указатель только на следующий элемент. Для хранения списка используется его вершина (Head), для доступа ко всем остальным элементам нужно совершить обход по ссылкам по докнца списка.
public class SnakeNode
{
    public SnakeNode Next; // указатель на следующий элемент змейки
    // координаты
    public int X;
    public int Y;
}

Для яблока объявим класс Apple

public class Apple
{
    public int X;
    public int Y;
}

Обновим перечисление MoveDirection которое будет определять сторону движения головы змейки на следующем ходу

public enum MoveDirection
{
     Left, Right, Top, Bottom
}

Объявим переменные необходимые для работы программы:

const int MaxApples = 10; // количество яблок на поле
int Rows => (int)(ClientRectangle.Height / CellSize); // количество строк
int Cols => (int)(ClientRectangle.Width / CellSize); // количество столбцов
bool gameOver = false; // флаг окончания игры
SnakeNode Head = new SnakeNode(); // голова змейки
public List Apples = new List(); // список всех яблок
MoveDirection dir; // текущее направление змейки
float CellSize = 30; // размер ячейки нашей игры 
Random Random = new Random(); // генератор случайных чисел

Сделаем функцию NewApple которая будет генерировать новое яблоко на игровом поле. Воспользуемся циклом с постусловием do..while. Случайным образом найдем позицию на карте, которая не занята ни змейкой ни другим яблоком и добавим новое яблоко в список яблок Apples.

public void NewApple() //сгенерировать новое яблоко и добавить его на карту
{
    int x = 0;
    int y = 0;
    do 
    {
        x = Random.Next(1, Cols - 1);
        y = Random.Next(1, Rows - 1);
    }
    while (Apples.Any(z => z.X == x && z.Y == y) || SnakeHas(x, y));

    Apples.Add(new Apple() { X = x, Y = y });
}

private bool SnakeHas(int x, int y) // функция проверки что данная клектка поля занята змейкой
{
        var node = Head;
        while (node != null)
        {
            if (node.X == x && node.Y == y)
                return true;

            node = node.Next;
        }
        return false;
}

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

private bool CheckCollisions(SnakeNode head)
{
    if (head.X == 0 || head.Y == 0 || head.X == Cols - 1 || head.Y == Rows - 1)                            
        return true;            

    var node = Head;
    while (node != null)
    {
        if (node.X == head.X && node.Y == head.Y)
            return true;
        
        node = node.Next;
    }

    return false;
}

Добавим обработчик кнопок , для этого перегрузим встроенную в Form функцию ProcessCmdKey

protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
      switch (keyData)
      {
          case Keys.Left:
              if (dir != MoveDirection.Right)
                  dir = MoveDirection.Left;
              break;
          case Keys.Right:
              if (dir != MoveDirection.Left)
                  dir = MoveDirection.Right;
              break;
          case Keys.Up:
              if (dir != MoveDirection.Bottom)
                  dir = MoveDirection.Top;
              break;
          case Keys.Down:
              if (dir != MoveDirection.Top)
                  dir = MoveDirection.Bottom;
              break;
      }
      return base.ProcessCmdKey(ref msg, keyData);
} 

Рассмотри функцию обновления сцены. Создадим новый элмент змейки, который будет новой головой в случае успеха. Если змейка врезалась в препятствие то игра окончена, выставим флаг gameOver. Иначе, заменим Head на новый элемент и сошлемся на старую голову змейки. Как было сказано ранее для хранения змейки удобно использовать односвязанный список. В частности для сдвига змейки на одну клетку вперед достаточно создать новый SnakeNode задав для него ссылку на следующий элемент Next равный старому Head. И обновить указатель Head на вновь созданный элемент SnakeNode. При перемещении змейки все элементы остаются на своих местах кроме первого и последних элментов. Последний элемент должнен быть удален, для этого нужно пройтись до конца односвязанного списка и удалить последний элемент путем задания предпоследнему элементу Next = null.

Поскольку последний элемент змейки экземпляр класса SnakeNode останется без ссылок на него, спустя некоторое время он будет собран GC (сборщиком мусора) среды CLR и память которую он занимал освободится.
SnakeNode newHead = new SnakeNode(); // создадим новый узел для предполагаемой позиции головы змейки на следующем ходу

// в начале зададим новой голове координаты старой головы
newHead.X = Head.X;
newHead.Y = Head.Y;

switch (dir) // проверим в какую сторону аправлена голова змейки и сделаем соответствующее приращение координат
{
    case MoveDirection.Left:
        newHead.X--;
        break;
    case MoveDirection.Right:
        newHead.X++;
        break;
    case MoveDirection.Top:
        newHead.Y--;
        break;
    case MoveDirection.Bottom:
        newHead.Y++;
        break;
}

if (CheckCollisions(newHead)) // теперь проверим пересечение новой позиции головы с объектами на карте
{
    gameOver = true; // если произошло столкновение то выставим флаг окончания игры
    return;
}

// если столкновения не произошло
newHead.Next = Head; // свяжем новый элемент головы со страрой головой
Head = newHead; // обнови указатель Head начала змейки на новый
 

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

bool removeLastNode = true;
if (Apples.Any(z => z.X == Head.X && z.Y == Head.Y))
{
    Apples.RemoveAll(z => z.X == Head.X && z.Y == Head.Y);
    NewApple();
    removeLastNode = false;
}

if (!removeLastNode)
    return;

//remove last node
var node = Head;
while (true)
{
    if (node.Next != null && node.Next.Next == null)
    {
        node.Next = null;
        break;
    }
    node = node.Next;
}
 

Добавим функцию сброса игры ResetGame

private void ResetGame()
{
        Apples.Clear();
        Head = new SnakeNode();
        gameOver = false;
        Head.X = (int)(ClientRectangle.Width / CellSize / 2);
        Head.Y = (int)(ClientRectangle.Height / CellSize / 2);

        var w = (int)(ClientRectangle.Width % CellSize);
        var h = (int)(ClientRectangle.Height % CellSize);
        Size = new Size(Size.Width - w, Size.Height - h);

        dir = MoveDirection.Left;

        for (int i = 0; i < MaxApples; i++)
            NewApple();

} 

Добавим функции отрисовки всех элементов нашей игры:

private void DrawSnake(Graphics gr) // нарисовать всю змейку целиком
{
      DrawNode(Head, gr, CellSize, Brushes.Orange);
      var node = Head.Next;
      while (node != null)
      {
          DrawNode(node, gr, CellSize * 0.9f, Brushes.LightGreen);
          node = node.Next;
      }
}

public void DrawNode(SnakeNode node, Graphics gr, float size, Brush brush) // нарисовать элемент змейки
{
    gr.DrawEllipse(Pens.Black, node.X * CellSize + (CellSize - size) / 2, node.Y * CellSize + (CellSize - size) / 2, size, size);
    gr.FillEllipse(brush, node.X * CellSize + (CellSize - size) / 2, node.Y * CellSize + (CellSize - size) / 2, size, size);
}

public void DrawWall(Graphics gr, int x, int y) //нарисовать один элемент стены
{
    gr.DrawRectangle(Pens.Black, x * CellSize, y * CellSize, CellSize, CellSize);
    gr.FillRectangle(Brushes.Thistle, x * CellSize, y * CellSize, CellSize, CellSize);
}

public void DrawAllWalls(Graphics gr) // нарисовать всю стену целиком
{
    for (int i = 0; i < Cols; i++)
    {
        DrawWall(gr, i, 0);
        DrawWall(gr, i, Rows - 1);
    }
    for (int i = 0; i < Rows; i++)
    {
        DrawWall(gr, 0, i);
        DrawWall(gr, Cols - 1, i);
    }
}

  

Запустив данный пример мы увидим следующую анимацию:

Исходный код данного примера вы можете найти здесь

Упражнения

HI