Своя змейка, или пишем первый проект. Часть 0 +14


Предисловие


Привет Хабр! Меня зовут Евгений «Nage», и я начал заниматься программированием около года назад, в свободное от работы время. Просмотрев множество различных туториалов по программированию задаешься вопросом «а что же делать дальше?», ведь в основном все рассказывают про самые основы и дальше как правило не заходят. Вот после продолжительного времени за просмотром разных роликов про одно и тоже я решил что стоит двигаться дальше, и браться за первый проект. И так, сейчас мы разберем как можно написать игру «Змейка» в консоли со своими начальными знаниями.

Глава 1. Итак, с чего начнем?


Для начала нам ничего лишнего не понадобится, только блокнот (или ваш любимый редактор), и компилятор C#, он присутствует по умолчанию в Windows, находится он в С:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe. Можно использовать компилятор последней версии который поставляется с visual studio, он находится Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\Roslyn\csc.exe.

Создадим файл для быстрой компиляции нашего кода, сохранил файл с расширением .bat со следующим содержимым:

@echo off
:Start
set /p name= Enter program name: 
echo.
С:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe "%name%.cs"
echo.
goto Start

"@echo off" отключает отображение команд в консоли. С помощью команды goto получаем бесконечный цикл. Задаем переменную name, а с модификатором /p в переменную записывается значение введенное пользователем в консоль. «echo.» просто оставляет пустую строчку в консоли. Далее вызываем компилятор и передаем ему файл нашего кода, который он скомпилирует.

Таким способом мы можем скомпилировать только один файл, поэтому мы будем писать все классы в одном документе (я не разобрался еще как компилировать несколько файлов в один .exe через консоль, да и это не тема нашей статьи, может кто нибудь расскажет в комментариях).

Для тех кто сразу хочет увидеть весь код.

Скрытый текст
using System;
using System.Threading;
using System.Collections.Generic;
using System.Linq;

namespace SnakeGame
{
    class Game
    {
        static readonly int x = 80;
        static readonly int y = 26;

        static Walls walls;
        static Snake snake;
        static FoodFactory foodFactory;
        static Timer time;

        static void Main()
        {
            Console.SetWindowSize(x + 1, y + 1);
            Console.SetBufferSize(x + 1, y + 1);
            Console.CursorVisible = false;

            walls = new Walls(x, y, '#');
            snake = new Snake(x / 2, y / 2, 3);

            foodFactory = new FoodFactory(x, y, '@');
            foodFactory.CreateFood();

            time = new Timer(Loop, null, 0, 200);

            while (true)
            {
                if (Console.KeyAvailable)
                {
                    ConsoleKeyInfo key = Console.ReadKey();
                    snake.Rotation(key.Key);
                }
            }
        }// Main()

        static void Loop(object obj)
        {
            if (walls.IsHit(snake.GetHead()) || snake.IsHit(snake.GetHead()))
            {
                time.Change(0, Timeout.Infinite);
            }
            else if (snake.Eat(foodFactory.food))
            {
                foodFactory.CreateFood();
            }
            else
            {
                snake.Move();
            }
        }// Loop()
    }// class Game

    struct Point
    {
        public int x { get; set; }
        public int y { get; set; }
        public char ch { get; set; }
        
        public static implicit operator Point((int, int, char) value) => 
              new Point {x = value.Item1, y = value.Item2, ch = value.Item3};

        public static bool operator ==(Point a, Point b) => 
                (a.x == b.x && a.y == b.y) ? true : false;
        public static bool operator !=(Point a, Point b) => 
                (a.x != b.x || a.y != b.y) ? true : false;

        public void Draw()
        {
            DrawPoint(ch);
        }
        public void Clear()
        {
            DrawPoint(' ');
        }

        private void DrawPoint(char _ch)
        {
            Console.SetCursorPosition(x, y);
            Console.Write(_ch);
        }
    }

    class Walls
    {
        private char ch;
        private List<Point> wall = new List<Point>();

        public Walls(int x, int y, char ch)
        {
            this.ch = ch;

            DrawHorizontal(x, 0);
            DrawHorizontal(x, y);
            DrawVertical(0, y);
            DrawVertical(x, y);
        }

        private void DrawHorizontal(int x, int y)
        {
            for (int i = 0; i < x; i++)
            {
                Point p = (i, y, ch);
                p.Draw();
                wall.Add(p);
            }
        }

        private void DrawVertical(int x, int y)
        {
            for (int i = 0; i < y; i++)
            {
                Point p = (x, i, ch);
                p.Draw();
                wall.Add(p);
            }
        }

        public bool IsHit(Point p)
        {
            foreach (var w in wall)
            {
                if (p == w)
                {
                    return true;
                }
            }
            return false;
        }
    }// class Walls

    enum Direction
    {
        LEFT,
        RIGHT,
        UP,
        DOWN
    }

    class Snake
    {
        private List<Point> snake;

        private Direction direction;
        private int step = 1;
        private Point tail;
        private Point head;

        bool rotate = true;

        public Snake(int x, int y, int length)
        {
            direction = Direction.RIGHT;

            snake = new List<Point>();
            for (int i = x - length; i < x; i++)
            {
                Point p = (i, y, '*');
                snake.Add(p);

                p.Draw();
            }
        }

        public Point GetHead() => snake.Last();

        public void Move()
        {
            head = GetNextPoint();
            snake.Add(head);

            tail = snake.First();
            snake.Remove(tail);

            tail.Clear();
            head.Draw();

            rotate = true;
        }

        public bool Eat(Point p)
        {
            head = GetNextPoint();
            if (head == p)
            {
                snake.Add(head);
                head.Draw();
                return true;
            }
            return false;
        }

    public Point GetNextPoint () 
    {
        Point p = GetHead ();

        switch (direction) 
        {
        case Direction.LEFT:
            p.x -= step;
            break;
        case Direction.RIGHT:
            p.x += step;
            break;
        case Direction.UP:
            p.y -= step;
            break;
        case Direction.DOWN:
            p.y += step;
            break;
        }
        return p;
    }

    public void Rotation (ConsoleKey key) 
    {
        if (rotate) 
        {
            switch (direction) 
            {
            case Direction.LEFT:
            case Direction.RIGHT:
                if (key == ConsoleKey.DownArrow)
                    direction = Direction.DOWN;
                else if (key == ConsoleKey.UpArrow)
                    direction = Direction.UP;
                break;
            case Direction.UP:
            case Direction.DOWN:
                if (key == ConsoleKey.LeftArrow)
                    direction = Direction.LEFT;
                else if (key == ConsoleKey.RightArrow)
                    direction = Direction.RIGHT;
                break;
            }
            rotate = false;
        }

    }

        public bool IsHit(Point p)
        {
            for (int i = snake.Count - 2; i > 0; i--)
            {
                if (snake[i] == p)
                {
                    return true;
                }
            }
            return false;
        }
    }//class Snake

    class FoodFactory
    {
        int x;
        int y;
        char ch;
        public Point food { get; private set; }

        Random random = new Random();

        public FoodFactory(int x, int y, char ch)
        {
            this.x = x;
            this.y = y;
            this.ch = ch;
        }

        public void CreateFood()
        {
            food = (random.Next(2, x - 2), random.Next(2, y - 2), ch);
            food.Draw();
        }
    }
}


Глава 2. Первые шаги


Подготовим поле нашей игры, начиная с точки входа в нашу программу. Задаем переменные X и Y, размер и буфер окна консоли, и скроем отображение курсора.

using System;
using System.Collections.Generic;
using System.Linq;
class Game{
    static readonly int x = 80;
    static readonly int y = 26;

    static void Main(){
        Console.SetWindowSize(x + 1, y + 1);
        Console.SetBufferSize(x + 1, y + 1);
        Console.CursorVisible = false;
    }// Main()
}// class Game

Для вывода на экран нашей «графики» создадим свой тип данных — точка. Он будет содержать координаты и символ, который будет выводится на экран. Также сделаем методы для вывода на экран точки и ее «стирания».

struct Point{
    public int x { get; set; }
    public int y { get; set; }
    public char ch { get; set; }

    public static implicit operator Point((int, int, char) value) => 
        new Point {x = value.Item1, y = value.Item2, ch = value.Item3};

    public void Draw(){
        DrawPoint(ch);
    }
    public void Clear(){
        DrawPoint(' ');
    }
    private void DrawPoint(char _ch){
        Console.SetCursorPosition(x, y);
        Console.Write(_ch);
    }
}

Это интересно!
Оператор => называется лямбда-оператор, он используется в качестве определения анонимных лямбда выражений, и в качестве тела, состоящего из одного выражения, синтаксический сахар, заменяющий оператор return. Приведенный выше метод переопределения оператора (про его назначение чуть ниже) можно переписать так:

public static bool operator ==(Point a, Point b){
    if (a.x == b.x && a.y == b.y){
        return true;
    }
    else{
        return false;
    }
}


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

class Walls{
    private char ch;
    private List<Point> wall = new List<Point>();

    public Walls(int x, int y, char ch){
        this.ch = ch;
        DrawHorizontal(x, 0);
        DrawHorizontal(x, y);
        DrawVertical(0, y);
        DrawVertical(x, y);
    }

    private void DrawHorizontal(int x, int y){
        for (int i = 0; i < x; i++){
            Point p = (i, y, ch);
            p.Draw();
            wall.Add(p);
        }
    }
    private void DrawVertical(int x, int y) {
        for (int i = 0; i < y; i++) {
            Point p = (x, i, ch);
            p.Draw();
            wall.Add(p);
        }
    }
}// class Walls

Это интересно!

Как вы могли заметить для инициализации типа данных Point используется форма Point p = (x, y, ch); как и у встроенных типов, это становится возможным при переопределении оператора implicit, в котором описывается как задаются переменные.

Важно!

Конструкция (int, int, char) называется кортежем, и работает только с .net 4.7+, по этому если у вас не установлен visual studio, то в вашем распоряжении только компилятор v4.0.30319 и нужно использовать стандартную инициализацию через оператор new.

Вернемся к классу Game и объявим поле walls, а в методе Main инициализируем ее.

class Game{
static Walls walls;
    static void Main(){
        walls = new Walls(x, y, '#');
...

Все! Можно скомпилировать код и посмотреть, что наше поле построилось, и самая легкая часть позади.

Глава 3. А что сегодня на завтрак?


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

class FoodFactory
{
    int x;
    int y;
    char ch;
    public Point food { get; private set; }

    Random random = new Random();

    public FoodFactory(int x, int y, char ch)
    {
        this.x = x;
        this.y = y;
        this.ch = ch;
    }

    public void CreateFood()
    {
        food = (random.Next(2, x - 2), random.Next(2, y - 2), ch);
        food.Draw();
    }
}

Добавляем инициализацию фабрики и создадим еду на поле
class Game{
    static FoodFactory foodFactory;

    static void Main(){
        foodFactory = new FoodFactory(x, y, '@');
        foodFactory.CreateFood();
...

Кушать подано!

Глава 4. Время главного героя


Перейдем к созданию самой змеи, и для начала определим перечисление направления движения змейки.

enum Direction{
    LEFT,
    RIGHT,
    UP,
    DOWN
}

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

class Snake{
    private List<Point> snake;
    private Direction direction;
    private int step = 1;
    private Point tail;
    private Point head;
    bool rotate = true;
    public Snake(int x, int y, int length){
        direction = Direction.RIGHT;
        snake = new List<Point>();
        for (int i = x - length; i < x; i++)        {
            Point p = (i, y, '*');
            snake.Add(p);
            p.Draw();
        }
    }
//Методы движения и поворота в зависимости он направления движения змейки.
    public Point GetHead() => snake.Last();
    public void Move(){
        head = GetNextPoint();
        snake.Add(head);
        tail = snake.First();
        snake.Remove(tail);
        tail.Clear();
        head.Draw();
        rotate = true;
    }
    public Point GetNextPoint() {
        Point p = GetHead();
        switch (direction) {
            case Direction.LEFT:
                p.x -= step;
                break;
            case Direction.RIGHT:
                p.x += step;
                break;
            case Direction.UP:
                p.y -= step;
                break;
            case Direction.DOWN:
                p.y += step;
                break;
        }
    return p;
    }
    public void Rotation(ConsoleKey key) {
        if (rotate) {
            switch (direction) {
                case Direction.LEFT:
                case Direction.RIGHT:
                    if (key == ConsoleKey.DownArrow)
                        direction = Direction.DOWN;
                    else if (key == ConsoleKey.UpArrow)
                        direction = Direction.UP;
                    break;
                case Direction.UP:
                case Direction.DOWN:
                    if (key == ConsoleKey.LeftArrow)
                        direction = Direction.LEFT;
                    else if (key == ConsoleKey.RightArrow)
                        direction = Direction.RIGHT;
                    break;
            }
            rotate = false;
        }
    }
}//class Snake

В методе поворота, что бы избежать возможности повернуть сразу на 180 градусов, просто указываем, что в каждом направлении мы можем повернуть только в 2 стороны. А проблему поворота на 180 градусов двумя нажатиями — поставив «переключатель», отключаем возможность поворачивать после первого нажатия, и включаем после очередного хода.

Осталось вывести ее на экран.

class Game{
    static Snake snake;
    static void Main(){
        snake = new Snake(x / 2, y / 2, 3);
...

Готово! теперь у нас есть все что нужно, поле огороженное стенами, рандомно появляющаяся еда, и змейка. Пришла пора заставить все это взаимодействовать друг с другом.

Глава 5. Л-логика


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

class Game {
    static void Main () {
        while (true) {
            if (Console.KeyAvailable) {
                ConsoleKeyInfo key = Console.ReadKey ();
                snake.Rotation(key.Key);
            }
...

для движения змеи воспользуемся классом .net который будет запускать метод Loop через определенные промежутки времени.

using System.Threading;
class Game {
    static Timer time;
    static void Main () {
        time = new Timer (Loop, null, 0, 200);
...

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

struct Point {
    public static bool operator == (Point a, Point b) => 
        (a.x == b.x && a.y == b.y) ? true : false;
    public static bool operator != (Point a, Point b) => 
        (a.x != b.x || a.y != b.y) ? true : false;
...

Теперь можно написать метод, который будет проверять совпадает ли интересующая нас точка с какой нибудь из массива стен.
class Walls {
    public bool IsHit (Point p) {
        foreach (var w in wall) {
            if (p == w) {
                return true;
            }
        }
        return false;
    }
...

И похожий метод проверяющий не совпадает ли точка с хвостом.

class Snake {
    public bool IsHit (Point p) {
        for (int i = snake.Count - 2; i > 0; i--) {
            if (snake[i] == p) {
                return true;
            }
        }
        return false;
    }
...

И методом проверки съела ли еду наша змейка, и сразу делаем ее длиннее.

class Snake {
    public bool Eat (Point p) {
        head = GetNextPoint ();
        if (head == p) {
            snake.Add (head);
            head.Draw ();
            return true;
        }
        return false;
    }
...

теперь можно написать метод движения, со всеми нужными проверками.

class Snake {
    static void Loop (object obj) {
        if (walls.IsHit (snake.GetHead ()) || snake.IsHit (snake.GetHead ())) {
            time.Change (0, Timeout.Infinite);
        } else if (snake.Eat (foodFactory.food)) {
            foodFactory.CreateFood ();
        } else {
            snake.Move ();
        }
    }
...

Вот и все! Наша змейка в консоли закончена и можно поиграть.


Заключение


Мы посмотрели как можно реализовать первую простенькую игру с небольшим использованием ООП, научились перегружать операторы, посмотрели на кортежи и лямбда оператор, надеюсь это было полезно!

Это была пилотная статья, и если вам понравилось, я напишу про реализацию змейки на Unity.
Всем удачи!




К сожалению, не доступен сервер mySQL