Создано при помощи нейросети Kandinsky

Пишем 2D-игру "Змейка" на C# для Windows Forms (~200 строк кода)

User Rating: 5 / 5

Изображение для статьи создано при помощи нейросети Kandinsky

Всем привет!

В этой статье мы разберём с вами, как написать популярную (если не сказать, всемирно известную) игру "Змейка" на языке C#. В базовом варианте - без различных улучшений и "красивостей" в плане графики/музыки - весь основной код игры займет у нас приблизительно 200 строк исходного кода на C#. Затем мы дополнительно рассмотрим с вами вариант оптимизации базового варианта игры, где добавим различные функции, немного улучшим графику и геймплей, добавим визуальных эффектов в игру и статистику с её отображением для игрока. По понятным причинам после таких улучшений базового варианта объем кода у нас несколько увеличится, однако отражённые в статье улучшения - это лишь один из возможных предложенных мной вариантов, и они опциональны. Вы всегда сможете взять базовый вариант и при желании оптимизировать игру на свой вкус и цвет.

В самом конце статьи будут приложены архивы с готовыми проектами для среды Microsoft Visual Studio, которые мы с вами разработаем в рамках статьи: один проект будет содержать базовый вариант "Змейки", второй - оптимизированный, с улучшенной 2D-графикой.

Некоторые важные ограничения в игре, которые мы предусмотрим (т.к. некоторые варианты реализации "Змейки" могут отличаться по механике):

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

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

Базовый вариант:

Оптимизированный вариант:

Итак, поехали. Разработаем базовый вариант "Змейки".

Часть 1. Создаём новый проект в среде разработки Microsoft Visual Studio и готовим главную форму для игры

Для начала нам нужно создать новый проект в среде разработки. Для этого выбираем при создании тип проекта "Приложение Windows Forms (.NET Framework)". В качестве имени проекта указываем SnakeGameExample, местоположение проекта выбираем на свой вкус.

После того, как проект создан, в окне "Обозреватель решений" вы увидите дерево проекта и созданную по умолчанию форму Form1.cs. Переименуем её сразу в FrmSnakeGame.cs и при выдаче диалогового окна с запросом на переименование всех связанных ссылок - соглашаемся.

Сразу выставим некоторые свойства для главной формы:

  • FormBorderStyle: FixedSingle
  • Text: Allineed.Ru - Пример игры "Змейка" на C#
  • Size: 700; 521
  • StartPosition: CenterScreen
  • MaximizeBox: False

Также нам потребуется всего один элемент управления на форме - с типом Timer. Добавим его из панели элементов, перетащив на главную форму проекта, после чего установим ему следующие значения свойств:

  • Name: TimerGameLoop
  • Interval: 300

На этом конфигурация свойств формы завершена, и мы переходим к разработке игры.

Часть 2. Пишем код игры "Змейка" для главной формы (FrmSnakeGame)

Начнём мы с того, что дважды кликнем по главной форме проекта, находясь в режиме визуального конструктора. Это приведёт к генерации пустого обработчика для события Load формы, который будет срабатывать при загрузке главной формы игры.

Сразу в самом верху исходного кода формы пропишем все необходимые импорты, которые нам потребуются в игре:

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;

Далее мы напишем пока пустой закрытый (private) метод с именем StartGame в классе формы и добавим в код обработчика загрузки формы - FrmSnakeGame_Load - следующий код:

        private void FrmSnakeGame_Load(object sender, EventArgs e) {
            DoubleBuffered = true;
            BackColor = Color.Black;
            StartGame();
        }

        private void StartGame() {
        }

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

Подробнее про это свойство можно прочитать в документации.

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

Теперь поднимемся в самое начало класса FrmSnakeGame для главной формы и добавим все необходимые поля, которые нам потребуются в игре:

        private const int CELL_SIZE_PIXELS = 30;                    // Размер клетки игрового поля, в пикселях
        private const int ROWS_NUMBER = 15;                         // Количество рядов в игровом поле
        private const int COLS_NUMBER = 15;                         // Количество столбцов в игровом поле
        private const int FIELD_LEFT_OFFSET_PIXELS = 40;            // Отступ в пикселях от левого края формы
        private const int FIELD_TOP_OFFSET_PIXELS = 15;             // Отступ в пикселях от правого края формы
        private const int INITIAL_SNAKE_SPEED_INTERVAL = 300;       // Задержка (свойство "Interval") для основного игрового таймера TimerGameLoop
        private const int SPEED_INCREMENT_BY = 5;                   // На сколько миллисекунд увеличить скорость "Змейки" при очередном поглощении змейкой "Еды"
        
        private enum SnakeDirection {
            Left,
            Right,
            Up,
            Down
        }

        private SnakeDirection snakeDirection = SnakeDirection.Up;  // Текущее направление движения "Змейки"
        private LinkedList<Point> snake = new LinkedList<Point>();  // Список точек, содержащих координаты всего "тела Змейки"
        private Point food;                                         // Точка, содержащая координаты "Еды" для "Змейки"
        private Random rand = new Random();                         // Генератор псевдослучайных чисел. нужен для генерации очередной "Еды" в произвольном месте игрового поля
        private bool isGameEnded;                                   // Признак: игра завершена?

Назначение всех полей представлено в сопутствующих комментариях. Внутренний закрытый enum-тип SnakeDirection будет определять возможные направления движения "Змейки":

  • Left - "Змейка" движется влево
  • Right - "Змейка" движется вправо
  • Up - "Змейка" движется вверх
  • Down - "Змейка" движется вниз

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

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;

namespace SnakeGameExample {
    public partial class FrmSnakeGame : Form {
        private const int CELL_SIZE_PIXELS = 30;                    // Размер клетки игрового поля, в пикселях
        private const int ROWS_NUMBER = 15;                         // Количество рядов в игровом поле
        private const int COLS_NUMBER = 15;                         // Количество столбцов в игровом поле
        private const int FIELD_LEFT_OFFSET_PIXELS = 40;            // Отступ в пикселях от левого края формы
        private const int FIELD_TOP_OFFSET_PIXELS = 15;             // Отступ в пикселях от правого края формы
        private const int INITIAL_SNAKE_SPEED_INTERVAL = 300;       // Задержка (свойство "Interval") для основного игрового таймера TimerGameLoop
        private const int SPEED_INCREMENT_BY = 5;                   // На сколько миллисекунд увеличить скорость "Змейки" при очередном поглощении змейкой "Еды"
        
        private enum SnakeDirection {
            Left,
            Right,
            Up,
            Down
        }

        private SnakeDirection snakeDirection = SnakeDirection.Up;  // текущее направление движения "Змейки"
        private LinkedList<Point> snake = new LinkedList<Point>();  // Список точек, содержащих координаты всего "тела Змейки"
        private Point food;                                         // Точка, содержащая координаты "Еды" для "Змейки"
        private Random rand = new Random();                         // генератор псевдослучайных чисел. нужен для генерации очередной "Еды" в произвольном месте игрового поля
        private bool isGameEnded;                                   // признак: игра завершена?

        public FrmSnakeGame() {
            InitializeComponent();
        }

        private void FrmSnakeGame_Load(object sender, EventArgs e) {
            DoubleBuffered = true;
            BackColor = Color.Black;
            StartGame();
        }

        private void StartGame() {
        }
    }
}

Также видим, что при создании формы для нас был автоматически добавлен метод конструктора формы с именем FrmSnakeGame(). Добавим сразу после этого метода конструктора следующий новый метод с именем InitializeSnake():

        private void InitializeSnake() {
            snakeDirection = SnakeDirection.Up;
            snake.Clear();
            snake.AddFirst(new Point(ROWS_NUMBER - 1, COLS_NUMBER / 2 - 1));
        }

Как можно видеть, он отвечает за первичную инициализацию "Змейки":

  • устанавливает ей начальное направление движения - "вверх": snakeDirection = SnakeDirection.Up;
  • очищает связанный список точек с координатами всех клеток "Змейки" (очистка изначально пустого списка здесь нужна, т.к. метод будет вызываться не только при старте игры, но и при её перезапуске, когда нужно будет вновь создать только "голову" змейки, без "тела"): snake.Clear(); 
  • добавляет в связанный список snake первую клетку "Змейки". Это голова "Змейки", и её координаты - это самый низ игрового поля, т.е. самый нижний ряд (его индекс равен ROWS_NUMBER - 1), а расположение "головы" по горизонтали - примерно посередине игрового поля (индекс столбца равен COLS_NUMBER / 2 - 1).

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

Для этого добавляем в метод StartGame() вызов нового метода InitializeSnake():

        private void StartGame() {
            InitializeSnake();
        }

К слову, сам метод StartGame() у нас сейчас вызывается при загрузке формы, однако в дальнейшем мы будем его вызывать также после завершении игры, чтобы начать игру заново и повторно инициализировать все её показатели.

Теперь давайте создадим в коде главной формы метод по генерации "Еды" для "Змейки" - это, по сути, будет какая-то произвольная клетка игрового поля, которая не может пересекаться с клетками поля, где расположена сама "Змейка".

Назовём этот метод GenerateFood():

        private void GenerateFood() {
            bool isFoodClashWithSnake;
            do {
                food = new Point(rand.Next(0, ROWS_NUMBER), rand.Next(0, COLS_NUMBER));
                isFoodClashWithSnake = false;
                foreach (Point p in snake) {
                    if (p.X == food.X && p.Y == food.Y) {
                        isFoodClashWithSnake = true;
                        break;
                    }
                }
            } while (isFoodClashWithSnake);
            
            TimerGameLoop.Interval -= SPEED_INCREMENT_BY;
        }

Алгоритм метода по генерации "Еды" довольно простой:

  1. В начале метода мы объявляем булеву переменную isFoodClashWithSnake. Когда в ней будет значение true, это будет означать, что очередная произвольно сгенерированная клетка поля пересеклась с телом "Змейки". Соответственно, значение false скажет о том, что пересечения "Еды" со "Змейкой" нет, и мы можем поместить "Еду" на игровое поле в нужных, произвольно сгенерированных, координатах.
  2. Дальше идет цикл do-while. Условием выхода из этого цикла является признак того, что "Еду" можно разместить на поле, и нет пересечения со "Змейкой".
    1. Внутри цикла мы получаем очередной экземпляр структуры Point с произвольными координатами в пределах игрового поля. Рандомизацию ряда и столбца клетки поля для "Еды" осуществляем вызовами rand.Next(0, ROWS_NUMBER) и rand.Next(0, COLS_NUMBER), которые всегда сгенерируют допустимое значение ряда и столбца для клетки поля.
    2. Далее мы поворачиваем флаг isFoodClashWithSnake в значение false, тем самым утверждая, что пересечения сгенерированной клетки "Еды" со "Змейкой" пока нет.
    3. Внутренний цикл foreach нужен для перебора всех клеток поля, где расположена "Змейка", и внутри этого цикла мы проверяем: совпадают ли координаты текущей клетки "Змейки" с координатами только что сгенерированной клетки "Еды"? Если совпадают, мы взводим флажок isFoodClashWithSnake в значение true (т.е. пересечение "Еды" и "Змейки" возникло) и при помощи оператора break тут же выходим из цикла foreach. Таким образом, перед циклом foreach флажок isFoodClashWithSnake устанавливается так, чтоб выйти из внешнего цикла do-while, если нет пересечений. Если пересечения возникли, флаг будет взведён, и мы будем продолжать "крутить" цикл do-while до тех пор, пока не будет пересечения.
  3. Перед выходом из метода GenerateFood() мы ускоряем игру: поскольку в методе мы генерируем новую "Еду", это значит, что "Змейка" перед этим "скушала" предыдущую "Еду". А значит, можно ускорить "Змейку", делая процесс игры более увлекательным и одновременно более сложным при увеличении длины тела "Змейки".

Добавим теперь вызов этого нового метода в метод StartGame(), а также сбросим признак окончания игры isGameEnded в значение false, запустим игровой таймер - TimerGameLoop.Start() - и выставим значение интервала для таймера в исходное - в значение константы INITIAL_SNAKE_SPEED_INTERVAL:

        private void StartGame() {
            GenerateFood();
            InitializeSnake();
            isGameEnded = false;
            TimerGameLoop.Start();
            TimerGameLoop.Interval = INITIAL_SNAKE_SPEED_INTERVAL;
        }

Теперь мы перейдем к рисованию игрового поля, отрисовке самой "Змейки" и отрисовке "Еды".

Вернёмся к представлению визуального конструктора главной формы игры и в окне "Свойства" перейдем к событиям для формы (иконка "молнии"). Сгенерируем обработчик для события Paint - двойным кликом напротив имени события. В результате снова откроется редактор кода главной формы, и мы увидим следующий сгенерированный пустой метод-обработчик:

        private void FrmSnakeGame_Paint(object sender, PaintEventArgs e) {

        }

Первым делом мы получим в новую локальную переменную g экземпляр класса Graphics из аргументов этого события (параметр e с типом PaintEventArgs):

        private void FrmSnakeGame_Paint(object sender, PaintEventArgs e) {
            Graphics g = e.Graphics;
        }

Этот экземпляр класса Graphics мы теперь будем использовать для отрисовки всех игровых объектов ("Змейка" и "Еда"), а также игрового поля и передавать в качестве аргумента для вспомогательных методов отрисовки игровых объектов. Создадим три метода - DrawGrid, DrawSnake, DrawFood, - каждый из которых на вход принимает параметр с типом Graphics:

        private void DrawGrid(Graphics g) {
            for (int row = 0; row <= ROWS_NUMBER; row++) {
                g.DrawLine(Pens.Cyan, 
                    new Point(FIELD_LEFT_OFFSET_PIXELS, FIELD_TOP_OFFSET_PIXELS + row * CELL_SIZE_PIXELS), 
                    new Point(FIELD_LEFT_OFFSET_PIXELS + CELL_SIZE_PIXELS * ROWS_NUMBER, FIELD_TOP_OFFSET_PIXELS + row * CELL_SIZE_PIXELS)
                );

                for (int col = 0; col <= COLS_NUMBER; col++) {
                    g.DrawLine(Pens.Cyan, 
                        new Point(FIELD_LEFT_OFFSET_PIXELS + col * CELL_SIZE_PIXELS, FIELD_TOP_OFFSET_PIXELS), 
                        new Point(FIELD_LEFT_OFFSET_PIXELS + col * CELL_SIZE_PIXELS, FIELD_TOP_OFFSET_PIXELS + CELL_SIZE_PIXELS * COLS_NUMBER)
                    );
                }
            }
        }

        private void DrawSnake(Graphics g) {
            foreach (Point p in snake) {
                g.FillRectangle(Brushes.Lime, new Rectangle(
                    FIELD_LEFT_OFFSET_PIXELS + p.Y * CELL_SIZE_PIXELS + 1,
                    FIELD_TOP_OFFSET_PIXELS + p.X * CELL_SIZE_PIXELS + 1,
                    CELL_SIZE_PIXELS - 1,
                    CELL_SIZE_PIXELS - 1));
            }
        }

        private void DrawFood(Graphics g) {
            g.FillRectangle(Brushes.Red, new Rectangle(
                FIELD_LEFT_OFFSET_PIXELS + food.Y * CELL_SIZE_PIXELS + 1,
                FIELD_TOP_OFFSET_PIXELS + food.X * CELL_SIZE_PIXELS + 1,
                CELL_SIZE_PIXELS - 1,
                CELL_SIZE_PIXELS - 1));
        }

По названиям методов нетрудно догадаться, за что они отвечают:

  • DrawGrid(Graphics g) - метод рисует всё игровое поле в виде клеток. Это делается двумя циклами for, сначала по рядам (внешний цикл), затем - по столбцам (внутренний цикл). Пробегая по рядам, мы просто рисуем горизонтальные линии, зная отступ от левого края формы (константа класса FIELD_LEFT_OFFSET_PIXELS), а также зная размер каждой клетки игрового поля в пикселях (константа класса CELL_SIZE_PIXELS). Аналогичным образом во внутреннем цикле for рисуются вертикальные линии "сетки" игрового поля. В итоге мы получаем поле из клеток размером ROWS_NUMBER x COLS_NUMBER.
  • DrawSnake(Graphics g) - метод рисует саму "Змейку". Алгоритм рисования очень прост: всего один цикл foreach по всем точкам (экземпляры структуры Point) "Змейки". Внутри этого цикла мы рисуем заполненный прямоугольник (при помощи метода FillRectangle, доступного в классе Graphics), который на самом деле является квадратом с длиной стороны, равной (CELL_SIZE_PIXELS - 1) пикселей. В качестве начальной координаты квадрата по оси X мы указываем выражение FIELD_LEFT_OFFSET_PIXELS + p.Y * CELL_SIZE_PIXELS + 1. Оно означает, что мы берём отступ игрового поля слева от главной формы, к нему прибавляем произведение индекса столбца на размерность клетки игрового поля: p.Y * CELL_SIZE_PIXELS. Именно столбца (он у нас в p.Y), поскольку нам нужен расчёт координаты X, который хранится как раз в точке "текущего звена Змейки". Затем мы прибавляем ещё единицу, чтобы клетка "Змейки" не "налезала" на границы клетки, а отрисовка была внутри границ клетки (по этой же причине от длины стороны квадрата мы отнимали единицу: CELL_SIZE_PIXELS - 1). Тот же принцип используется для вычисления начальной координаты квадрата по оси Y, только там мы индекс ряда берём из p.X. Наконец, в качестве кисти для заполнения клетки поля мы используем кисть с цветом лайма (Brushes.Lime) - чтобы наша "Змейка" в игре была зелёной. Если вы захотите перекрасить "Змейку" в какой-то другой цвет, то здесь самое место для того, чтобы выбрать любую другую кисть, которая вам по душе.
  • DrawFood(Graphics g) - метод рисует "Еду" для "Змейки", которая представляется одной клеткой поля, закрашенной красным цветом (кисть Brushes.Red). Метод очень похож по структуре на внутреннее наполнение цикла foreach, который мы только что рассмотрели в методе DrawSnake, здесь разница лишь в том, что никакого цикла нам не нужно - координаты клетки с "Едой" у нас уже хранятся в переменной food класса главной формы.

Теперь нам всего лишь нужно вызвать все эти методы из обработчика события Paint, передавая в аргументах каждого вызова подготовленную переменную g класса Graphics:

        private void FrmSnakeGame_Paint(object sender, PaintEventArgs e) {
            Graphics g = e.Graphics;
            DrawGrid(g);
            DrawFood(g);
            DrawSnake(g);
        }

Таким образом мы делегировали отрисовку разных игровых объектов соответствующим отдельным методам, что рассмотрели выше. И когда теперь главная форма будет вызывать событие Paint для своей перерисовки, у нас будут рисоваться сразу все игровые объекты: сетка игрового поля, "Змейка" и "Еда".

Напишем отдельный небольшой метод с именем GameOver(). Несложно догадаться, что он отвечает за отработку действий, когда произошёл конец игры. Конец игры может случиться в двух ситуациях: "Змейка" попыталась съесть саму себя или её голова дошла до одной из границ игрового поля, а игрок не успел развернуть "Змейку" в одном из направлений движения, не приводящих к поражению. Метод будет взводить флаг isGameEnded, отвечающий за признак завершения игры, останавливать таймер TimerGameLoop для игрового цикла, а также выводить диалоговое окно для игрока с вопросом о желании начать игру заново:

        private void GameOver() {
            isGameEnded = true;
            TimerGameLoop.Stop();
            if (MessageBox.Show("Конец игры! Начать заново?", "Конец игры", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes) {
                StartGame();
            }
        }

Если игрок нажмёт "Да" в диалоговом окне, то игра будет перезапущена - за счёт вызова метода StartGame().

Теперь давайте в классе формы напишем метод, который будет отвечать за передвижение "Змейки". Этот метод мы так и назовём - MoveSnake(), а ниже представлен его код:

        private void MoveSnake() {
            LinkedListNode<Point> head = snake.First;
            Point newHead = new Point(0, 0);
            switch (snakeDirection) {
                case SnakeDirection.Left:
                    newHead = new Point(head.Value.X, head.Value.Y - 1);
                    break;
                case SnakeDirection.Right:                    
                    newHead = new Point(head.Value.X, head.Value.Y + 1);
                    break;
                case SnakeDirection.Down:                    
                    newHead = new Point(head.Value.X + 1, head.Value.Y);
                    break;
                case SnakeDirection.Up:             
                    newHead = new Point(head.Value.X - 1, head.Value.Y);
                    break;
            }

            if (snake.Any(point => point.X == newHead.X && point.Y == newHead.Y)) {
                // "Змейка" съела саму себя! Конец игры!
                Invalidate();
                GameOver();
                return;
            }

            snake.AddFirst(newHead);

            if (newHead.X == food.X && newHead.Y == food.Y) {
                GenerateFood();
            } else {                
                snake.RemoveLast();
            }            
        }

Разберём алгоритм этого метода:

  • Вначале мы получаем в переменную head самую первую точку из списка с именем snake, который хранит все точки с координатами игрового поля, где расположено тело "Змейки". Самая первая точка - это голова "Змейки", поэтому и переменная называется head.
  • Далее, мы заготавливаем в переменной newHead новую точку, по умолчанию инициализируя её координатами (0, 0). Это та точка, в которой будет следующая ближайшая позиция головы "Змейки" после осуществления ей движения в текущем направлении.
  • В операторе switch мы проверяем текущее направление движения "Змейки" и устанавливаем реальные координаты следующей позиции головы "Змейки". Если "Змейка" движется влево, то мы от координаты Y текущей головы "Змейки" отнимаем единицу, тем самым получая столбец игрового поля левее текущего столбца, где расположена голова "Змейки". Все остальные развилки оператора switch выстроены по этому же принципу - разница лишь в изменяемой координате (X - строки игрового поля, Y - столбцы) для текущего направления движения.
  • После оператора switch идёт оператор if, в котором мы проверяем: "а не съела ли только что Змейка саму себя?". Для этого мы пытаемся найти среди всех точек (Point) тела "Змейки" ту, которая полностью по координатам совпадает с координатами новой головы "Змейки" (newHead). Если нашлась такая точка, у которой X и Y полностью совпадает с новой позицией головы "Змейки", то это значит, что игра окончена, т.к. произошло "самосъедание Змейки". В этом случае мы вызываем перерисовку всей формы при помощи вызова метода Invalidate(), который, в свою очередь, вызовет событие Paint и перерисует всю видимую область главной формы. Далее мы вызываем уже рассмотренный выше метод GameOver(), после чего выходим из метода MoveSnake() - за счёт оператора return.
  • Если "Змейка" не съела саму себя, то в оператор if мы не входим, и вызывается snake.AddFirst(newHead). Это добавляет "новую голову" для "Змейки" - в самое начало списка snake
  • В самом конце - также оператор if, где идёт проверка: "Не произошло ли поедание Змейкой текущей еды?". Если произошло, нам пора сгенерировать новую "Еду", и мы вызываем метод GenerateFood(), при этом мы ничего не делаем со списком snake, т.е. не удаляем из него "хвост". За счёт этого "Змейка" увеличивается в размерах на одну клетку. Если же не произошло поедание "Еды", то мы попадём в ветку else, где мы удаляем последний элемент списка snake: это "хвост" нашей "Змейки", который за счёт удаления из списка очистит клетку игрового поля, из которой он "уполз".

Основной алгоритм игры написан, и мы почти завершаем разработку игры. Нам осталось реализовать всего 4 несложных метода.

Напишем метод IsGameOver(), которые вернет true, если произошёл конец игры, и "Змейка" врезалась в края игрового поля, не успев развернуться, и false - в противном случае (т.е. игра продолжается):

        private bool IsGameOver() {
            LinkedListNode<Point> head = snake.First;
            switch (snakeDirection) {
                case SnakeDirection.Left:
                    return head.Value.Y - 1 < 0;
                case SnakeDirection.Right:
                    return head.Value.Y + 1 >= COLS_NUMBER;
                case SnakeDirection.Down:
                    return head.Value.X + 1 >= ROWS_NUMBER;                    
                case SnakeDirection.Up:
                    return head.Value.X - 1 < 0;
            }
            return false;
        }

Алгоритм этого метода очень прост: из списка snake мы получаем голову "Змейки", т.е. первый элемент списка. Далее мы смотрим на текущее направление движения "Змейки" и делаем расчёт: "не выйдет ли голова Змейки ближайшим следующим ходом за пределы игрового поля?". Для направлений движения Left и Right мы смотрим, чтобы координата Y (отвечает за столбцы) у головы "Змейки" не выходила за пределы размеров игрового поля. Если это случилось, метод возвращает true. Аналогично для направлений движения Up и Down: если голова "Змейки" попытается следующим ходим выйти за пределы первого или последнего ряда, то также возвращаем true из метода. Если же все проверки прошли, и мы не вышли из метода, то в самом конце возвращаем false, что означает: "игра продолжается, Змейка продолжает движение".

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

        private void TimerGameLoop_Tick(object sender, EventArgs e) {
            if (IsGameOver()) {
                GameOver();
            } else {
                MoveSnake();
                Invalidate();
            }            
        }

Т.е. таймер срабатывает исходно (при старте игры) с периодичностью 300 миллисекунд. И с этой периодичностью мы сперва проверяем - не произошёл ли конец игры только что? (вызов метода IsGameOver() в операторе if). Если случился конец игры, вызываем метод GameOver(). В противном же случае игра продолжается, и мы вызываем всего два метода: MoveSnake() и Invalidate(). Первый, как мы помним, передвигает "Змейку" в направлении её движения, а второй - вызывает перерисовку всей области главной формы.

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

Напишем следующий короткий метод ChangeSnakeDirection, который будет отвечать за смену направления движения "Змейки":

        private void ChangeSnakeDirection(SnakeDirection restrictedDirection, SnakeDirection newDirection) {
            if (snakeDirection != restrictedDirection) {
                snakeDirection = newDirection;
            }
        }

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

Вторым же параметром newDirection мы будем передавать желаемое новое направление движения "Змейки". Это желаемое направление чуть ниже мы будем регулировать нажатиями определённых клавиш на клавиатуре.

Таким образом, алгоритм этого нового метода таков: если текущее направление движения "Змейки" не равно некоторому запрещённому (restrictedDirection), то поменять направление "Змейки" на новое, заданное параметром newDirection.

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

        private void FrmSnakeGame_KeyDown(object sender, KeyEventArgs e) {
            switch (e.KeyCode) {
                case Keys.Left:
                case Keys.A:
                    ChangeSnakeDirection(SnakeDirection.Right, SnakeDirection.Left);                    
                    break;
                case Keys.Right:
                case Keys.D:
                    ChangeSnakeDirection(SnakeDirection.Left, SnakeDirection.Right);
                    break;
                case Keys.Down:
                case Keys.S:
                    ChangeSnakeDirection(SnakeDirection.Up, SnakeDirection.Down);
                    break;
                case Keys.Up:
                case Keys.W:
                    ChangeSnakeDirection(SnakeDirection.Down, SnakeDirection.Up);
                    break;
                case Keys.Escape:
                    TimerGameLoop.Stop();
                    Close();
                    break;
                case Keys.Space:
                    if (isGameEnded && !TimerGameLoop.Enabled) {
                        StartGame();
                    }
                    break;
            }
        }

Можно видеть, что логика этого обработчика заключена в одном-единственном операторе switch, где мы проверяем код текущей нажатой клавиши: Для клавиш "стрелка влево" или "A" мы вызываем смену направления движения "Змейки" на "движение влево" (SnakeDirection.Left). При этом запрещённым значением для смены направления движения будет являться признак, что "Змейка" уже движется вправо (это регулирует первый аргумент SnakeDirection.Right при вызове метода ChangeSnakeDirection). Это сделано для запрета поворота "Змейки" на 180°, т.е. влево, ведь если её голова уже движется вправо, повернуть резко налево нельзя. По такому же принципу происходит обработка нажатия клавиш для остальных направлений движения:

  • Для клавиш "стрелка вправо" или "D" мы вызываем смену направления движения "Змейки" на "движение вправо" (SnakeDirection.Right)
  • Для клавиш "стрелка вниз" или "S" мы вызываем смену направления движения "Змейки" на "движение вниз"  (SnakeDirection.Down)
  • Для клавиш "стрелка вверх" или "W" мы вызываем смену направления движения "Змейки" на "движение вверх"  (SnakeDirection.Up)

В случае нажатия клавиши Escape, мы останавливаем таймер игрового цикла - TimerGameLoop.Stop() - и просто закрываем главную форму приложения при помощи вызова Close(), что приводит к завершению игры. Здесь нет никаких диалоговых окон "Вы уверены, что хотите выйти из игры?". При желании добавления такой логики вы без труда сможете это сделать, я решил сделать вариант "быстрого выхода" без лишних вопросов игроку.

Последнее - обработка нажатия на клавишу пробела (Keys.Space): здесь мы проверим, что если игра закончена, и игровой таймер выключен, то пробел запустит игру заново, вызвав метод StartGame().

Что ж, друзья, код игры "Змейка" (базовый вариант) на этом полностью готов и написан, и уже можно попробовать игру в действии! Запустите сейчас проект разработанной игры (клавишей F5 или через панель инструментов в Microsoft Visual Studio), если проходили все шаги вместе со мной, и протестируйте геймплей. Если же вы не разрабатывали проект по статье, а просто читали её и разбирали основные шаги и алгоритмы, а теперь хотите посмотреть, как же всё это работает, то просто скачайте в конце статьи готовый проект для базового варианта "Змейки" и запустите его.

Теперь опциональная и бонусная часть - про то, как можно оптимизировать базовый вариант "Змейки", добавив к нему различные улучшения.

Часть 3. Разрабатываем оптимизированный вариант игры "Змейка"

Оптимизированный вариант игры "Змейка" будет строиться на основе базового: мы просто возьмём весь код базового варианта, рассмотренного выше, и применим к нему ряд изменений. Несомненно, объем кода у нас вырастет: без учёта комментариев он будет составлять порядка 390 строк кода.

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

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

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

  • TimerGameLoop_Tick - обработчик события Tick таймера
  • IsGameOver - метод, проверяющий, не врезалась ли "Змейка" в одну из границ игрового поля, что является также признаком окончания игры
  • FrmSnakeGame_Load - обработчик события Load для главной формы игры
  • DrawGrid - метод отрисовки сетки игрового поля
  • InitializeSnake - метод инициализации "Змейки"

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

  • DrawStatsAndKeyboardHints - новый метод для отрисовки игровой статистики и подсказок по управлению в игре
  • TimerFoodBlink_Tick - новый метод-обработчик события Tick для нового таймера, который добавим на форму для поддержки "мерцания" у "Еды"
  • PauseOrUnpauseGame - метод будет ставить игру на паузу и снимать с паузы

Методы, код которых мы изменим (относительного базового варианта игры):

  • DrawFood - метод для отрисовки "Еды"
  • DrawSnake - метод для отрисовки "Змейки"
  • GenerateFood - метод для генерации новой "Еды"
  • GameOver - метод, выполняющий действия по завершению игры
  • StartGame - метод, выполняющий действия для начала игры

Теперь перейдем непосредственно к коду и доработаем ранее рассмотренный базовый вариант игры. Для начала давайте добавим вверху класса FrmSnakeGame для главной формы некоторые новые поля, которые нам потребуются. В комментариях к каждому такому новому полю указан префикс [Оптимизированный вариант], если этого поля нет в базовом варианте игры:

using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;

namespace SnakeGameExample {
    public partial class FrmSnakeGame : Form {        
        private const int CELL_SIZE_PIXELS = 30;                    // Размер клетки игрового поля, в пикселях
        private const int ROWS_NUMBER = 15;                         // Количество рядов в игровом поле
        private const int COLS_NUMBER = 15;                         // Количество столбцов в игровом поле
        private const int FIELD_LEFT_OFFSET_PIXELS = 40;            // Отступ в пикселях от левого края формы
        private const int FIELD_TOP_OFFSET_PIXELS = 15;             // Отступ в пикселях от правого края формы
        private const int INITIAL_SNAKE_SPEED_INTERVAL = 300;       // Задержка (свойство "Interval") для основного игрового таймера TimerGameLoop
        private const int SPEED_INCREMENT_BY = 5;                   // На сколько миллисекунд увеличить скорость "Змейки" при очередном поглощении змейкой "Еды"        
        private const bool ENABLE_KEYDOWN_DELAY = true;             // [Оптимизированный вариант] Разрешить функцию задержки при нажатии клавиш?
        private const int KEYDOWN_DELAY_MILLIS = 450;               // [Оптимизированный вариант] Минимальное количество миллисекунд, которое должно пройти между последовательными нажатиями клавиш
        private enum SnakeDirection {
            Left,
            Right,
            Up,
            Down
        }

        private SnakeDirection snakeDirection = SnakeDirection.Up;      // Текущее направление движения "Змейки"
        private LinkedList<Point> snake = new LinkedList<Point>();      // Список точек, содержащих координаты всего "тела Змейки"
        private Point food;                                             // Точка, содержащая координаты "Еды" для "Змейки"
        private Random rand = new Random();                             // Генератор псевдослучайных чисел. нужен для генерации очередной "Еды" в произвольном месте игрового поля
        private bool isGameEnded;                                       // Признак: игра завершена?
        
        private int foodAlpha = 255;                                    // [Оптимизированный вариант] Значение для Альфа-канала для цвета "Еды". Необходимо для эффекта "мерцания" еды на игровом поле
        private int foodAlphaInc = -25;                                 // [Оптимизированный вариант] Текущее значение, которое будет по таймеру прибавляться к значению foodAlpha
        private bool isGamePaused;                                      // [Оптимизированный вариант] Признак: поставлена ли игра на паузу?
        private Stopwatch keyPressSensivityStopwatch = new Stopwatch(); // [Оптимизированный вариант] Объект Stopwatch для подсчёта количества миллисекунд между нажатиями клавиш игроком
        private int points = 0;                                         // [Оптимизированный вариант] Статистика: количество очков, набранных игроком в игре
        private int foodEaten = 0;                                      // [Оптимизированный вариант] Статистика: количество "Еды", поглощённой "Змейкой"

        public FrmSnakeGame() {
            InitializeComponent();
        }

        // ... остальной код класса ...
    }
}

Теперь мы добавим в код класса новый метод DrawStatsAndKeyboardHints - он будет отвечать за вывод статистики на форму и подсказок по управлению в игре:

        private void DrawStatsAndKeyboardHints(Graphics g) {
            Font fontStats = new Font("Consolas", 14);
            int statsLeftOffset = FIELD_LEFT_OFFSET_PIXELS + CELL_SIZE_PIXELS * COLS_NUMBER + 10;            
            g.DrawString(string.Format("Длина змейки: {0}", snake.Count), fontStats, Brushes.Lime, new Point(statsLeftOffset, 10));
            g.DrawString(string.Format("Скорость: {0}", INITIAL_SNAKE_SPEED_INTERVAL - TimerGameLoop.Interval + 5), fontStats, Brushes.Lime, new Point(statsLeftOffset, 30));
            g.DrawString(string.Format("Очки: {0}", points), fontStats, Brushes.Goldenrod, new Point(statsLeftOffset, 50));
            g.DrawString(string.Format("Еды съедено: {0}", foodEaten), fontStats, Brushes.Crimson, new Point(statsLeftOffset, 70));

            g.DrawString("Управление:", fontStats, Brushes.White, new Point(statsLeftOffset, 160));
            g.DrawString("Вверх: ↑ или W", fontStats, Brushes.White, new Point(statsLeftOffset, 190));
            g.DrawString("Вниз:  ↓ или S", fontStats, Brushes.White, new Point(statsLeftOffset, 210));
            g.DrawString("Влево: ← или A", fontStats, Brushes.White, new Point(statsLeftOffset, 230));
            g.DrawString("Влево: → или D", fontStats, Brushes.White, new Point(statsLeftOffset, 250));
            g.DrawString("Пауза: [Space]", fontStats, Brushes.White, new Point(statsLeftOffset, 270));
            g.DrawString("Старт: [Space]", fontStats, Brushes.White, new Point(statsLeftOffset, 290));
            g.DrawString("Выход: [Escape]", fontStats, Brushes.White, new Point(statsLeftOffset, 310));

            if (isGamePaused) {
                g.DrawString("Игра на паузе...", fontStats, Brushes.Yellow, new Point(statsLeftOffset, 350));
            }
            fontStats.Dispose();
        }

Внутри метода мы создаём экземпляр шрифта fontStats для вывода подсказок на главную форму. Также в отдельную переменную statsLeftOffset мы записываем отступ каждой строки относительно левого края главной формы. Затем выводим различные параметры статистики игры и подсказки по управлению с дистанцией 20 пикселей по оси Y. Это значение было подобрано, исходя из выбранного шрифта экспериментально. Более правильным было бы использовать метод MeasureString из класса Graphics - это позволило бы не зависеть от конкретного шрифта, но для упрощения мы просто каждую новую строку выводим с подобранными интервалами по вертикали.

Стоит сказать пару слов про расчёт скорости игры: по коду выше видно, что мы рассчитываем скорость по формуле INITIAL_SNAKE_SPEED_INTERVAL - TimerGameLoop.Interval + 5. Поскольку свойство Interval у таймера уменьшается с каждым поеданием "Еды", то делать скорость на основе интервала таймера, которая будет уменьшаться, было бы некрасиво. Скорость "Змейки" должна наоборот возрастать с каждым поеданием "Еды". Поэтому от исходного значения интервала (300, задано константой класса INITIAL_SNAKE_SPEED_INTERVAL) мы вычитаем текущее значение свойства Interval таймера (а оно уменьшается с каждым поеданием "Еды"), и чтобы оно изначально не было равно 0, прибавляем к результату 5. Т.е. исходная скорость "Змейки" считается за 5. Затем она будет постепенно увеличиваться на 5 условных единиц.

В конце метода мы дополнительно можем вывести сообщение о том, что игра находится на паузе, при условии, что флаг isGamePaused выставлен в true.

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

        private void FrmSnakeGame_Paint(object sender, PaintEventArgs e) {
            Graphics g = e.Graphics;
            g.SmoothingMode = SmoothingMode.HighQuality;
            DrawGrid(g);
            DrawFood(g);
            DrawSnake(g);
            DrawStatsAndKeyboardHints(g);
        }

Мы также добавили установку режима сглаживания (свойство SmoothingMode у объекта класса Graphics) и установили сглаживание с высоким качеством. Это вторая строка кода, которой также не было в базовом варианте игры: g.SmoothingMode = SmoothingMode.HighQuality;

Теперь давайте поменяем метод DrawSnake. В этом методе мы вместо обычной кисти для заполнения клеток "Змейки" будем использовать кисть с поддержкой линейного градиента (LinearGradientBrush), а также прорисуем глаза и зрачки "Змейки", в зависимости от текущего направления её движения:

        private void DrawSnake(Graphics g) {
            int snakePoint = 0;
            foreach (Point p in snake) {
                Rectangle snakeBodyRectangle = new Rectangle(
                    FIELD_LEFT_OFFSET_PIXELS + p.Y * CELL_SIZE_PIXELS + 1,
                    FIELD_TOP_OFFSET_PIXELS + p.X * CELL_SIZE_PIXELS + 1,
                    CELL_SIZE_PIXELS - 1,
                    CELL_SIZE_PIXELS - 1);
                Brush brushSnakeBodyGradient = new LinearGradientBrush(snakeBodyRectangle, snakePoint == 0 ? Color.Black : Color.DarkGreen, snakePoint == 0 ? Color.DarkGreen : Color.Lime, 100, true);

                g.FillRectangle(brushSnakeBodyGradient, snakeBodyRectangle);
                brushSnakeBodyGradient.Dispose();

                if (snakePoint == 0) {
                    // snakePoint == 0 - это голова. Нарисуем глаза "Змейки"
                    int offsetLeftEyeX = 0, offsetLeftEyeCircleX = 0;
                    int offsetLeftEyeY = 0, offsetLeftEyeCircleY = 0;
                    int offsetRightEyeX = 0, offsetRightEyeCircleX = 0;
                    int offsetRightEyeY = 0, offsetRightEyeCircleY = 0;
                    int eyeWidth = 0;
                    int eyeHeight = 0;
                    switch(snakeDirection) {
                        case SnakeDirection.Left:
                            eyeWidth = 10; eyeHeight = 3; offsetLeftEyeX = 5; offsetLeftEyeY = 22; offsetRightEyeX = 5; offsetRightEyeY = 5;
                            offsetLeftEyeCircleX = 7; offsetRightEyeCircleX = 7; offsetLeftEyeCircleY = 22; offsetRightEyeCircleY = 5;
                            break;
                        case SnakeDirection.Right:
                            eyeWidth = 10; eyeHeight = 3; offsetLeftEyeX = CELL_SIZE_PIXELS - eyeWidth - 5; offsetLeftEyeY = 5; offsetRightEyeX = CELL_SIZE_PIXELS - eyeWidth - 5; offsetRightEyeY = 22;
                            offsetLeftEyeCircleX = CELL_SIZE_PIXELS - eyeWidth - 5 + 5; 
                            offsetRightEyeCircleX = CELL_SIZE_PIXELS - eyeWidth - 5 + 5; 
                            offsetLeftEyeCircleY = 5; offsetRightEyeCircleY = 22;
                            break;
                        case SnakeDirection.Up:
                            eyeWidth = 3; eyeHeight = 10; offsetLeftEyeX = 5; offsetLeftEyeY = 5; offsetRightEyeX = 22; offsetRightEyeY = 5;
                            offsetLeftEyeCircleX = offsetLeftEyeX;
                            offsetRightEyeCircleX = offsetRightEyeX;
                            offsetLeftEyeCircleY = offsetLeftEyeY + 2; offsetRightEyeCircleY = offsetRightEyeY + 2;
                            break;
                        case SnakeDirection.Down:
                            eyeWidth = 3; eyeHeight = 10; offsetLeftEyeX = 22; offsetLeftEyeY = CELL_SIZE_PIXELS - eyeHeight - 5; offsetRightEyeX = 5; offsetRightEyeY = CELL_SIZE_PIXELS - eyeHeight - 5;
                            offsetLeftEyeCircleX = offsetLeftEyeX;
                            offsetRightEyeCircleX = offsetRightEyeX;
                            offsetLeftEyeCircleY = CELL_SIZE_PIXELS - eyeHeight - 5 + 5; offsetRightEyeCircleY = CELL_SIZE_PIXELS - eyeHeight - 5 + 5;
                            break;
                    }
                    // Рисуем правый глаз "Змейки"
                    g.FillEllipse(Brushes.Yellow, new Rectangle(
                        FIELD_LEFT_OFFSET_PIXELS + p.Y * CELL_SIZE_PIXELS + 1 + offsetRightEyeX,
                        FIELD_TOP_OFFSET_PIXELS + p.X * CELL_SIZE_PIXELS + 1 + offsetRightEyeY,
                        eyeWidth,
                        eyeHeight));

                    // Рисуем зрачок для правого глаза "Змейки"
                    g.FillEllipse(Brushes.Black, new Rectangle(
                        FIELD_LEFT_OFFSET_PIXELS + p.Y * CELL_SIZE_PIXELS + 1 + offsetRightEyeCircleX,
                        FIELD_TOP_OFFSET_PIXELS + p.X * CELL_SIZE_PIXELS + 1 + offsetRightEyeCircleY,
                        3,
                        3));

                    // Рисуем левый глаз "Змейки"
                    g.FillEllipse(Brushes.Yellow, new Rectangle(
                        FIELD_LEFT_OFFSET_PIXELS + p.Y * CELL_SIZE_PIXELS + 1 + offsetLeftEyeX,
                        FIELD_TOP_OFFSET_PIXELS + p.X * CELL_SIZE_PIXELS + 1 + offsetLeftEyeY,
                        eyeWidth,
                        eyeHeight));

                    // Рисуем зрачок для левого глаза "Змейки"
                    g.FillEllipse(Brushes.Black, new Rectangle(
                        FIELD_LEFT_OFFSET_PIXELS + p.Y * CELL_SIZE_PIXELS + 1 + offsetLeftEyeCircleX,
                        FIELD_TOP_OFFSET_PIXELS + p.X * CELL_SIZE_PIXELS + 1 + offsetLeftEyeCircleY,
                        3,
                        3));
                }
                snakePoint++;
            }
        }

Можно видеть, что теперь при пробеге по всем точкам "Змейки" мы дополнительно считаем индекс текущей точки (snakePoint). Для головы "Змейки" индекс всегда равен 0, поэтому это особый случай, который мы обрабатываем для более детальной прорисовки головы: необходимо теперь уметь рисовать глаза и зрачки. Также можно заметить, что при создании экземпляра кисти (теперь с поддержкой градиентной заливки) мы также смотрим на индекс текущей точки (snakePoint) и голову "Змейки" рисуем на базе цветов Color.Black и Color.DarkGreen, а тело "Змейки" - на базе цветов Color.DarkGreen и Color.Lime. Это пары цветов, используемых для градиентной заливки - с углом равным 100 и с поддержкой масштабирования. Более подробно ознакомиться с параметрами класса LinearGradientBrush можно в документации.

Чтобы лучше понять смысловое значение переменных eyeWidth, eyeHeight и тех, которые начинаются с префикса offset, посмотрим на увеличенное схематичное изображение головы "Змейки" при её движении вправо (SnakeDirection.Right):

Из диаграммы видно, что:

  • CELL_SIZE_PIXELS - это константа класса главной формы, отвечающая за размерность клетки игрового поля, но вместе с тем, это также ширина и высота квадрата, который является головой "Змейки" (в реальности квадрат меньше на пару пикселей, но это уже детали).
  • offsetLeftEyeX и offsetRightEyeX - это смещение по оси X от левой границы головы "Змейки" описанного прямоугольника, в который вписан эллипс, представляющий собой левый и правый глаз "Змейки (жёлтого цвета), соответственно. Аналогично offsetLeftEyeY и offsetRightEyeY - это смещения по оси Y от верхней границы головы "Змейки" описанного прямоугольника, в который вписан эллипс, представляющий собой левый и правый глаз "Змейки, соответственно
  • offsetLeftEyeCircleX и offsetRightCircleX - это смещение по оси X от левой границы головы "Змейки" описанного квадрата, в который вписан круг, представляющий собой левый и правый зрачок "Змейки" (чёрного цвета), соответственно. Аналогично offsetLeftEyeCircleY и offsetRightCircleY - это смещение по оси Y от верхней границы головы "Змейки" описанного квадрата, в который вписан круг, представляющий собой левый и правый зрачок "Змейки", соответственно.
  • eyeWidth - это ширина основного описанного прямоугольника вокруг всего глаза "Змейки"
  • eyeHeight - это высота основного описанного прямоугольника вокруг всего глаза "Змейки"

На рисунке выше рассмотрен пример, когда "Змейка" движется вправо. Все вышеперечисленные параметры считаются несколько иначе для других направлений движения, но общий алгоритм расчёта всех параметров остаётся аналогичным рассмотренному примеру при движении "Змейки" влево.

Теперь давайте немного изменим метод DrawFood, который отвечает за отрисовку "Еды". Теперь "Еда" рисуется в виде круга, который залит градиентной заливкой, да ещё и "мерцает" в процессе игры.

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

        private void DrawFood(Graphics g) {
            Rectangle foodRectangle = new Rectangle(
                FIELD_LEFT_OFFSET_PIXELS + food.Y * CELL_SIZE_PIXELS + 1,
                FIELD_TOP_OFFSET_PIXELS + food.X * CELL_SIZE_PIXELS + 1,
                CELL_SIZE_PIXELS - 1,
                CELL_SIZE_PIXELS - 1);
            Brush brushFood = new LinearGradientBrush(foodRectangle, 
                Color.FromArgb(foodAlpha, Color.Crimson.R, Color.Crimson.G, Color.Crimson.B),
                Color.FromArgb(foodAlpha, Color.RosyBrown.R, Color.RosyBrown.G, Color.RosyBrown.B),
                100, true);
            g.FillEllipse(brushFood, foodRectangle);
            Brush brushFoodBorder = new SolidBrush(Color.FromArgb(foodAlpha, Color.Red.R, Color.Red.G, Color.Red.B));
            Pen penFoodBorder = new Pen(brushFoodBorder);
            g.DrawEllipse(penFoodBorder, foodRectangle);
            brushFood.Dispose();
            penFoodBorder.Dispose();
            brushFoodBorder.Dispose();
        }

Можно заметить основные отличия от базового варианта игры: во-первых, мы для удобства создаём отдельный экземпляр прямоугольника сохраняем его в переменную foodRectangle. На самом деле это будет квадрат, т.к. обе его стороны равны CELL_SIZE_PIXELS - 1.

Далее мы создаем кисть brushFood, которая также на базе класса LinearGradientBrush, рассмотренного ранее. Градиентная кисть помогает закрасить "Еду" также градиентной заливкой. 2-й и 3-й аргументы для конструктора кисти представляют собой выражения, создающие заданный цвет по четырём компонентам ARGB, где A - это альфа-канал, R - красный цвет, G - зелёный цвет, B - синий цвет. 

Сразу после создания экземпляра кисти при помощи метода FillEllipse мы рисуем круг, представляющий собой "Еду" и используем созданную кисть и ранее созданный прямоугольник foodRectangle, который задаёт описанный вокруг круга ("Еды") квадрат.

Мы также хотим отрисовать границу круга для "Еды", для чего сначала создаём кисть brushFoodBorder, а далее - на её основе - "ручку" (Pen) penFoodBorder. Кисть создаётся на базе RGB-компонентов красного цвета (Color.Red). Отрисовка границы круга производится через вызов метода DrawEllipse класса Graphics.

В конце метода все созданные ресурсы (кисти и ручки) очищаются через вызовы метода Dispose() для их экземпляров.

Наиболее важный для понимания момент здесь - это переменная класса foodAlpha, которая регулирует значение Альфа-канала для фона "Еды" и его границы (т.е. окружности). Значение этой переменной мы далее будем по таймеру менять в меньшую сторону от 255 до 0 и потом - обратно - от 0 до 255, тем самым создавая эффект "мерцания" клетки с "Едой".

Добавим в код класса новый метод с названием AddPlayerPoints:

        private void AddPlayerPoints() {
            if (food.X == 0 && food.Y == 0 || food.X == ROWS_NUMBER - 1 && food.Y == 0 || food.X == ROWS_NUMBER - 1 && food.Y == COLS_NUMBER - 1 || food.X == 0 && food.Y == COLS_NUMBER - 1) {
                points += 1000;
            } else if (food.X == 0 || food.X == ROWS_NUMBER - 1 || food.Y == 0 || food.Y == COLS_NUMBER - 1) {
                points += 500;
            } else {
                points += 250;
            }
        }

Назначение метода - это накопление набранных игроком очков при поедании "Змейкой" очередной "Еды". Бонусные очки в оптимизированном варианте игры мы храним в поле класса points. Здесь можно видеть, что мы по-разному поощряем игрока в зависимости от сложности забора "Еды":

  • для "угловых" положений "Еды" - когда она располагается в самых углах игрового поля - игрок получает +1000 бонусных очков к счёту, поскольку это наиболее сложные участки в игре, где нужно успевать вовремя повернуть "Змейку".
  • для "граничных" положений - т.е. когда "Еда" располагается по краям игрового поля - игрок получает +500 бонусных очков к счёту. Это чуть легче, чем забрать "Еду" в углах, но сложнее, чем когда она находится посередине игрового поля.
  • для обычных положений "Еды" мы прибавляем к счёту игрока 250 бонусных очков. Это обычные положения "Еды" на поле, которые не столь тяжело "собрать".

Теперь давайте, используя этот новый метод, доработаем также метод движения "Змейки" - MoveSnake. В оптимизированном варианте игры он выглядит так:

        private void MoveSnake() {
            LinkedListNode<Point> head = snake.First;
            Point newHead = new Point(0, 0);
            switch (snakeDirection) {
                case SnakeDirection.Left:
                    newHead = new Point(head.Value.X, head.Value.Y - 1);
                    break;
                case SnakeDirection.Right:                    
                    newHead = new Point(head.Value.X, head.Value.Y + 1);
                    break;
                case SnakeDirection.Down:                    
                    newHead = new Point(head.Value.X + 1, head.Value.Y);
                    break;
                case SnakeDirection.Up:             
                    newHead = new Point(head.Value.X - 1, head.Value.Y);
                    break;
            }

            foreach (Point p in snake) {
                if (p.X == newHead.X && p.Y == newHead.Y) {
                    // змейка съела сама себя! конец игры!
                    Invalidate();
                    GameOver();
                    return;
                }
            }

            snake.AddFirst(newHead);

            if (newHead.X == food.X && newHead.Y == food.Y) {
                // съели еду, вознаграждаем игрока очками
                AddPlayerPoints();

                // увеличиваем счётчик съеденной еды
                foodEaten++;

                // генерируем новую еду на поле
                GenerateFood();
            } else {                
                snake.RemoveLast();
            }            
        }

Можно заметить, что метод практически не поменялся относительного базового варианта игры. Всё, что мы тут изменили - это теперь при поедании "Змейкой" очередной "Еды" мы вызываем новый метод AddPlayerPoints, а также увеличиваем общий счётчик "съеденной еды" для отображения в статистике игры.

Также сделаем маленькое изменение в методе GenerateFood по генерации "Еды". В самый конец метода нужно добавить следующие две строки кода:

            foodAlpha = 255;
            foodAlphaInc = -25;

Они означают следующее: как только будет сгенерирована новая "Еда" на игровом поле, её значение Альфа-канала выставляется равным по умолчанию 255 (соответствует максимальной "яркости" для мерцания). А значение, которое будем прибавлять по таймеру к foodAlpha, выставляем -25, т.е. каждый "тик" таймера будет увеличивать прозрачность "Еды", снижая значение Альфа-канала на 25 единиц.

Давайте теперь добавим на форму новый элемент управления Timer и назовём его TimerFoodBlink. Все значения свойств оставим у него по умолчанию. Таймер будет срабатывать раз в 100 миллисекунд и будет обеспечивать эффект мерцания "Еды" для "Змейки" на игровом поле. Сразу же после добавления таймера на форму делаем по нему двойной клик для генерации метода-обработчика. В обработчике размещаем следующий код:

        private void TimerFoodBlink_Tick(object sender, EventArgs e) {            
            if (foodAlpha + 25 > 255) {
                foodAlphaInc = -25;
            } else if (foodAlpha - 25 < 0) {
                foodAlphaInc = 25;
            }
            foodAlpha += foodAlphaInc;
            Invalidate();
        }

Можно видеть, что алгоритм метода проверяет граничные значения для Альфа-канала (между 0 и 255) и заставляет переменную foodAlpha то уменьшаться в сторону 0, то увеличиваться в сторону 255. Также в каждом цикле "тика" таймера мы вызываем перерисовку всей главной формы при помощи вызова метода Invalidate()

Доработаем метод StartGame, теперь он выглядит следующим образом:

        private void StartGame() {
            GenerateFood();
            InitializeSnake();
            isGameEnded = false;
            isGamePaused = false;
            foodAlpha = 255; 
            foodAlphaInc = -25;
            points = 0;
            foodEaten = 0;
            TimerGameLoop.Start();
            TimerFoodBlink.Start();            
            TimerGameLoop.Interval = INITIAL_SNAKE_SPEED_INTERVAL;
        }

Видно, что мы добавили в него дополнительную установку значений по умолчанию для новых полей класса - isGamePaused, foodAlpha, foodAlphaInc, points, foodEaten. Также мы добавили запуск таймера для мерцания "Еды" - TimerFoodBlink.Start().

Далее мы добавим в код класса главной формы следующий новый метод PauseOrUnpauseGame:

        private void PauseOrUnpauseGame() {
            if (!isGamePaused) {
                TimerGameLoop.Stop();
                TimerFoodBlink.Stop();
                Invalidate();
            } else {
                TimerGameLoop.Start();
                TimerFoodBlink.Start();
            }
            isGamePaused = !isGamePaused;
        }

Его смысл довольно прост: как и говорит его название, он призван поставить игру на паузу или снять её с паузы. Постановка на паузу фактически означает останов обоих таймеров - TimerGameLoop и TimerFoodBlink и перерисовку формы через Invalidate (для актуализации внешнего вида всех игровых объектов, прежде чем мы перейдем в режим "пауза").

Если же мы снимаем игру с паузы, мы просто обратно запускаем оба таймера. А в конце метода мы всегда переворачиваем значение флага isGamePaused на противоположное.

Теперь самую малость доработаем метод GameOver. Здесь нужно добавить всего одну новую строку кода - для остановки таймера мерцания "Еды":

        private void GameOver() {
            isGameEnded = true;
            TimerGameLoop.Stop();
            TimerFoodBlink.Stop();  // [Оптимизированный вариант] Останавливаем таймер мерцания для "Еды"
            if (MessageBox.Show("Конец игры! Начать заново?", "Конец игры", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes) {
                StartGame();
            }
        }

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

Добавим следующий код в метод-обработчик для события KeyDown на форме, при этом часть ранее работавшего в базовом варианте игры кода мы оставим как есть, без изменений:

        private void FrmSnakeGame_KeyDown(object sender, KeyEventArgs e) {
            if (ENABLE_KEYDOWN_DELAY) {
                if (!keyPressSensivityStopwatch.IsRunning) {
                    keyPressSensivityStopwatch.Start();
                } else {
                    if (keyPressSensivityStopwatch.ElapsedMilliseconds < KEYDOWN_DELAY_MILLIS) {
                        return;
                    } else {
                        keyPressSensivityStopwatch.Restart();
                    }
                }
            }

            switch (e.KeyCode) {
                case Keys.Left:
                case Keys.A:
                    // ... код неизменен - всё как в базовом варианте игры
                    break;
                // ..........................................
                // ... код обработки нажатий на другие клавиши неизменен - всё как в базовом варианте игры
                // ..........................................
                case Keys.Escape:
                    TimerGameLoop.Stop();
                    TimerFoodBlink.Stop();  // [Оптимизированный вариант] Перед выходом из игры также остановить таймер мерцания "Еды"
                    Close();
                    break;
                case Keys.Space:
                    if (isGameEnded && !TimerGameLoop.Enabled) {
                        StartGame();
                    } else {
                        PauseOrUnpauseGame();   // [Оптимизированный вариант] Поставить игру на паузу или снять с паузы
                    }
                    break;
            }
        }

Можно заметить, что в самом начале обработчика добавилась проверка - включён ли режим задержки для нажатия клавиш (это константа ENABLE_KEYDOWN_DELAY класса главной формы). Если он включён, то мы во внутреннем условии проверяем - запущен ли секундомер (новое поле с именем keyPressSensivityStopwatch в классе главной формы)? Если ещё не был запущен - мы его запускаем (вызов метода Start), а если уже был запущен, то сравниваем количество миллисекунд, которое прошло с момента последнего нажатия игроком какой-либо клавиши, со значением KEYDOWN_DELAY_MILLIS, которое устанавливает минимальное время (в миллисекундах), которое должно пройти с момента последнего нажатия клавишиkeyPressSensivityStopwatch.ElapsedMilliseconds < KEYDOWN_DELAY_MILLIS

Если ещё прошло слишком мало времени, мы просто возвращаемся (оператор return) и не обрабатываем никакие нажатия на клавиши, пока не пройдет достаточно времени. Если же времени прошло больше, чем KEYDOWN_DELAY_MILLIS, то мы перезапускаем секундомер: keyPressSensivityStopwatch.Restart() и начинаем измерение прошедшего времени заново. При этом уходим далее к оператору switch - для обработки нажатий клавиш, как обычно.

Напоследок хочется отметить, что значение KEYDOWN_DELAY_MILLIS я установил равным 450 (т.е. должно пройти не менее 450 миллисекунд между последовательными нажатиями игроком клавиш, чтоб можно было разворачивать "Змейку"). Вы можете либо отключить этот режим вовсе, установив в классе значение false для константы ENABLE_KEYDOWN_DELAY, либо отрегулировать его на свой вкус. Этот режим тоже накладывает определённые ограничения на возможности игрока, т.к. с ним нужно очень отчётливо представлять когда и какую клавишу нажать, поскольку если в игре нажал случайно не на ту клавишу, то придётся ждать 450 миллисекунд до следующего поворота "Змейки". При этом если "Змейка" находится вблизи краёв игрового поля, это может привести к проигрышу.

Теперь оптимизированный вариант полностью разработан, мы учли все основные изменения относительного базового варианта игры и можем также запустить проект и проверить геймплей.

Ещё одна небольшая ремарка относительно кода для базового и оптимизированного вариантов: в методе GenerateFood для генерации "Еды" мы увеличиваем скорость игры через уменьшение свойства Interval главного таймера:

TimerGameLoop.Interval -= SPEED_INCREMENT_BY;

У внимательных читателей мог возникнуть вопрос: не произойдет ли ситуации, когда после очередного уменьшения свойства Interval на величину SPEED_INCREMENT_BY, мы "перевалим" за границу нуля? Может, нужно добавить проверку и при приближении интервала к нулевым значениям, больше не увеличивать скорость?

Честно, я не стал делать здесь никаких дополнительных проверок по следующей причине: если протестировать игру, выставив сразу минимальные значения для свойства Interval (скажем, 1 или 10) на старте игры, то "Змейка" двигается настолько быстро, что за ней просто нереально успеть 🙂 Думаю, что как бы хорошо игрок не играл, он всё равно не дойдет в игре до ситуации, когда произойдет попытка установить интервал таймера в отрицательное значение (что, конечно, привело бы к ошибке). Если хотите, можете добавить дополнительный оператор if, который проверит, что Interval не уйдет в отрицательные значения, перед очередным ускорением игры.

В заключение статьи прилагаю ссылки на оба архива с вариантами разработанной игры:

Если статья вам понравилась и интересно продолжение статей по теме разработки игр на C# - поставьте лайк. Так я пойму интерес читателей к теме GameDev-а и буду планировать написание и других статей с разбором написания интересных игр на C#.

Ну а пока на этом всё, спасибо за внимание, успехов!

Яндекс.Метрика