[Из песочницы] Своя змейка, или пишем первый проект. Часть 0
Предисловие
Привет Хабр! Меня зовут Евгений «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 ireadonly 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 wall = new List();
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 snake;
private Direction direction;
private int step = 1;
private Point tail;
private Point head;
public Snake(int x, int y, int length)
{
direction = Direction.RIGHT;
snake = new List();
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();
}
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)
{
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;
}
}
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);
}
}
Это интересно!
Оператор => называется лямбда-оператор, он используется в качестве определения анонимных лямбда выражений, и в качеств определение текста выражения. Приведенный выше метод переопределения оператора (про его назначение чуть ниже) можно переписать так: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 wall = new List();
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 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();
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.
Всем удачи!