Предисловие
Привет Хабр! Меня зовут Евгений «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.
Всем удачи!
Комментарии (32)
ewgeniy2004
04.02.2018 21:27В данном случае предлагаю вместо
написатьConsoleKeyInfo key = Console.ReadKey();
это устранит отображение в консоли нажатой пользователем клавиши из-за чего иногда часть змея пропадает.ConsoleKeyInfo key = Console.ReadKey(true);
А проблему поворота на 180 градусов двумя нажатиями — поставив «переключатель»
двумя быстрыми нажатиями змейку можно повернуть на 180 градусовewgeniy2004
04.02.2018 21:38«переключатель» — работает просто его почему то нет в коде в начале статьи
YaNeTu
05.02.2018 11:40Чтобы на 180 не поворачивалась
// Запрет разворота на 180 // Если попытка поворота на 180 градусов if ((_direction == Directions.Right && DirectionControl == Directions.Left) || (_direction == Directions.Left && DirectionControl == Directions.Right) || (_direction == Directions.Up && DirectionControl == Directions.Down) || (_direction == Directions.Down && DirectionControl == Directions.Up)) { //пропускаем изменение направления; } else { _direction = DirectionControl; }
ewgeniy2004
05.02.2018 20:48мой вариант решения
Direction _direction = direction; if (key == ConsoleKey.DownArrow) direction = Direction.DOWN; else if (key == ConsoleKey.UpArrow) direction = Direction.UP; else if(key == ConsoleKey.LeftArrow) direction = Direction.LEFT; else if (key == ConsoleKey.RightArrow) direction = Direction.RIGHT; Point p1 = snake.ElementAt(snake.Count - 2); Point p2 = GetNextPoint(); if (p1.Equals(p2)) direction = _direction;
FFoxDiArt
04.02.2018 21:27Я конечно понимаю, что Вы — начинающий программист, но использование в одном месте лямбд и таких вот конструций вызывает улыбку :)
(a.x == b.x && a.y == b.y) ? true : false
GNage Автор
04.02.2018 21:29Почему? По моему это классный способ написать метод в одну строчку.
FFoxDiArt
04.02.2018 21:35Не в лямбдах дело :) А в тавтологии вышеуказанного выражения. Достаточно было написать
(a.x == b.x && a.y == b.y)
GNage Автор
04.02.2018 21:51Да, я понял :) мы можем вернуть true/false и без оператора ?:
Habra-Mikhail
04.02.2018 22:04Логические выражения при их вычисление возвращают истинность (логическое значение true / false).
Так что без ?: будет даже лучше.
Neiro-Neko
04.02.2018 22:36+1Больше всего меня интересуют две вещи:
- Почему было принято решение использовать блокнот и консоль?
- Почему бы просто в начале игрового цикла (функции Loop) не считать все нажатия которые были и повернуть змейку в последнем направлении? Сразу отпадет необходимость в переключателе.
GNage Автор
04.02.2018 22:381. Ну что бы показать, что можно компилировать и без IDE, что сделать игру можно уже сейчас нечего не устанавливая (хотя сам и использовал возможности языка, которые можно скомпилировать только компилятором при VS).
2. Интересное предложение, думаю можно и так сделать.aquamakc
05.02.2018 11:191. Ну что бы показать, что можно компилировать и без IDE, что сделать игру можно уже сейчас нечего не устанавливая
Вспоминается бородатый анекдот:
лежит на диком пляже молодая изнывающая от скуки девушка. Тут из кустов выходит мужик в ластах, фраке и противогазе.
Девушка: мужчина, давайте займёмся любовью?
Мужик: хорошо, но только в одежде, стоя и в гамаке.
Девушка: о_О почему?
Мужик: Люблю преодолевать трудности.
OlegKozlov
06.02.2018 10:052. Если рассматривать это именно как игру «Змейку», а не упражнение по лямбдам, то нажатия игрока пропускать нельзя. Поворот змейки в обратную сторону как раз делается двумя быстрыми нажатиями двух стрелок на одном шаге, иначе повороты и развороты начинают страшно бесить из-за сложности попадания в тайминг (шаги игры очень мелкие). Короче, для комфортной игры нужен буферизованый ввод. При противоположных командах нужно брать последнюю, игнорируя попытки включить змее реверс.
nkozhevnikov
05.02.2018 07:06Выложите полный исходный код на Github или сюда под спойлер.
Уж очень интересно это собрать и сыграть.YaNeTu
05.02.2018 13:24Может и на мой код змейки взглянете.
Змейка отлажена. Новый функционал придумывается и дорабатывается
Особенность в том, что можно включить автопилот и смотреть как она сама еду ищет и ест.
github.com/Cuprumbur/Snake
Присоеденяйтесь
Автопилот включается кнопкой 'A'
impwx
05.02.2018 10:46+2Когда статья пишется «новичком для новичков», в нее неизбежно проникают «вредные советы» и прочие плохие практики. Разберем по пунктам:
1. Зачем использовать блокнот и консольный компилятор? Слава богу, в 2018 году живем, а не в каменном веке. Есть Visual Studio и VS Code — оба бесплатные, есть отличные автокомплит и отладчик.
2. Лексема=>
в данном случае не является «лямбда-оператором» — это просто синтаксический сахар дляreturn
. А «expression-bodied member» это не «определение текста выражения» (это что, гугл-транслейт?), а «тело, состоящее из одного выражения».
3. Кортежи в данном случае вообще не нужно использовать. ВместоPoint p = (1, 2, 'x')
можно писатьvar p = new Point(1, 2, 'x')
— получаем более осмысленные подсказки и ошибки.
4. Вместо того, чтобы хранитьstep
иdirection
, можно было бы хранить вторую переменную типаPoint
и складывать их, как вектора. Тогда функцияGetNextPoint
сократилась бы до одной строки.
5. КоллекцияList
в данном случае не подходит: удаление первого элемента приводит к тому, что все остальные приходится «сдвинуть» на один шаг назад. Для данного случая естьQueue
, поддерживающая как раз добавление с одной стороны и удаление с другой.
6. Как уже было сказано, не нужно писать(a == b) ? true : false
: это эквивалентно простоa == b
.GNage Автор
05.02.2018 12:22Вот ради таких комментов и стоило писать, спасибо :)
1. Все ради того, что бы показать, что можно взять файл с текстом и скомпилировать его без всяких сторонних программ, как по мне это полезно знать, может появится желание у людей поподробнее разобраться с этой темой, и в том как устроен компилятор.
2. Да это просто машинный перевод с сайта майкросовтов, поправлю в статье.
3. Я хотел что бы объявление класса выглядело как обычный тип данных, как int i = 1 например, и показать как этого можно добиться.
С остальным полностью согласен!impwx
05.02.2018 13:401. Инструменты разработчика — сильная сторона дотнета. Ими не стоит пренебрегать из аскетических соображений. Если хочется «быстро написать и сразу проверить» код — попробуйте LINQPad. Автокомплит там включается только в платной версии, но он точно стоит своих денег. Консольный же компилятор за ~7 лет работы с платформой еще ни разу не пригодился.
3. Похоже на то, что вы до этого работали со старыми стандартами C / C++ и пытаетесь перенести стиль кода в C#. Это плохая идея по двум причинам. Во-первых, такой код делает двойную работу: вместо того, чтобы передать аргументы сразу в конструктор нужного класса, вы сначала передаете их в конструктор кортежа, а потом забираете из него. Во-вторых, неявное приведение типов считается плохим тоном, поскольку затрудняет понимание кода, и допустимо только в очень редких случаях.
Советую поставить Resharper — там есть огромное количество правил, обнаруживающих «неопрятный» код и позволяющих автоматически его исправить. Со временем эти правила входят в привычку и код становится чище.GNage Автор
05.02.2018 14:36Я ни в коем случае не хотел принизить значимость IDE, но полезно знать что там под капотом:)
С# мой первый язык, с С я знакомился только когда пытался смотреть гарвардский курс cs50. С точки зрения производительности, да лучше сразу в конструктор передавать значения, это я понимаю.
Resharper посмотрю, интересно.
hd_keeper
05.02.2018 11:16Почему Nage, а не Uke?
GNage Автор
05.02.2018 11:46Странный вопрос, ник у меня такой
Sinatr
05.02.2018 12:24Похоже на отсылку к айкидо: «Uke (literally „one who receives“, the one who takes the fall) and nage (the thrower)», скорее всего имелось в виду, что как новичок, вы скорее всего будете Uke, когда Nage придут читать статью ;)
GNage Автор
05.02.2018 12:32как ни странно айкидо я занимался пару лет, но ник не имеет к нему отношения:)
EndUser
06.02.2018 05:51Microsoft ® Visual C# Compiler version 2.6.0.62329 (5429b35d) //Это Cummunity 2017
Copyright © Microsoft Corporation. All rights reserved.
Snake.cs(51,12): warning CS0660: 'Point' defines operator == or operator != but does not override Object.Equals(object o)
Snake.cs(51,12): warning CS0661: 'Point' defines operator == or operator != but does not override Object.GetHashCode()
Что я делаю не так? :-)GNage Автор
06.02.2018 10:34все нормально, компилятор просто предупреждает, что не реализованы эти два метода, но код все равно должен компилироваться, и можно запустить .exe
Shtucer
Ну, как же так? Просто перечисляете все файлы для компиляции в одной команде. Или делаете вот так, чтобы скомпилировать все файлы в текущей директории:
csc -out:%name%.exe *.cs
Первая же страница документации.
GNage Автор
спасибо :) буду знать