И всё же игра!
Всем снова привет! Рада что вы читаете это, ведь наша история о споре подходит к финальной стадии.
В прошлой статье я сделала наброски кода и спустя несколько дней (благодаря советам более опытных программистов) готова вам показать полностью переписанный с нуля код с объяснениями.
Скачать готовый код можно в конце статьи из моего рипозитория (если не можете дождаться).
Начнём с начала, разберём начальные объекты
Тут мы разберём что будет в нашем приложении и что к этому будем заматывать изолентой (конечно же кодом).
1-е, это конечно стены (Walls)
2-е, это наши игроки (Players)
3-е, снаряды (Shots)
Возникает вопрос, а как это систематизировать и заставить работать вместе?
Что способно создать систему? Конечно же структура, но у любой структуры должны быть параметры. Сейчас мы создадим первые из них — это координаты. Для их удобного отображения мы будем использовать следующий класс:
// Класс для удобного представления координат public class Position { // Публичные свойства класса public int X { get; set; } public int Y { get; set; } public Position(int x, int y) { X = x; Y = y; } }
Второй параметр — это отличительный номер, ведь каждый элемент структуры должен быть различен, поэтому в любой нашей структуре (внутри неё) обязательно будет поле ID.
Приступим к созданию структур на основе этого класса.
Далее я опишу структуры а потом и их взаимодействие.
Структура 'Игроки' (PlayerState)
Я выделила их отдельно, так как в них огромное количество методов и они очень важны (кто ещё будет играть и двигать модельками?).
Я просто скину поля и ниже начну их описывать:
private int ID { get; set; } private Position Position { get; set; } private Position LastPosition { get; set; } private int[] Collider_X { get; set; }// коллайдер private int[] Collider_Y { get; set; } private int hp { get; set; } //стартовое положение static int dir;
ID — это я уже успела объяснить
Position — это экземпляр одноимённого класса, LastPosition — предыдущая позиция
Collider — это коллайдер (те точки, попав в которые тебе отнимется здоровье)
Структура 'Игроки', должна содержать метода по обработке экземпляров структуры/подготовке нашего экземпляра к отправке на сервер, для этих задач мы используем следующие методы:
public static void hp_minus(PlayerState player, int hp) public static void NewPosition(PlayerState player, int X, int Y) private static bool ForExeption(Position startPosition) public static ShotState CreateShot(PlayerState player, int dir_player, int damage) public static void WriteToLastPosition(PlayerState player, string TEXT)
Первый метод — это метод на отнятие определённого количества здоровья у игрока.
Второй метод необходим для назначения новой позиции игрокам.
Третий мы используем в конструкторе, чтобы не было ошибок при создании танка.
Четвёртый создаёт выстрел (т.е. стреляет пулей из танка).
Пятый должен печатать текст в предыдущую позицию игрока (не обновлять же нам каждый кадр экран консоли путём вызова Console.Clear()).
Теперь по каждому методу отдельно, то есть разберём их код:
1-й:
/// <summary> /// Минус хп /// </summary> /// <param name="player">Игрок</param> /// <param name="hp">Сколько хп отнимаем</param> public static void hp_minus(PlayerState player, int hp) { player.hp -= hp; }
Я думаю что тут не много придётся объяснять, эта запись оператора полностью эквивалентна вот этой:
player.hp = player.hp - hp;
Остального же в этом методе не будет добавлено.
2-й:
/// <summary> /// Назначаем другим игрокам позиции /// </summary> /// <param name="player">Игрок</param> /// <param name="X">Координата X</param> /// <param name="Y">Координата Y</param> public static void NewPosition(PlayerState player, int X, int Y) { if ((X > 0 && X < Width) && (Y > 0 && Y < Height)) { player.LastPosition = player.Position; player.Position.X = X; player.Position.Y = Y; player.Collider_X = new int[3]; player.Collider_Y = new int[3]; player.Collider_Y[0] = Y; player.Collider_Y[1] = Y + 1; player.Collider_Y[2] = Y + 2; player.Collider_X[0] = X; player.Collider_X[1] = X + 1; player.Collider_X[2] = X + 2; } }
Тут мы комбинируем условия для того чтобы другой игрок (у нас на консоли, вдруг лаги будут) не смог уехать за игровое поле. Кстати поля мы используем (Height и Width) они обозначают границы нашего поля (Высота и Ширина).
3-й:
private static bool ForExeption(Position startPosition) { if (startPosition.X > 0 && startPosition.Y > 0) return true; return false; }
Тут же мы не даём координатам быть меньше размера игрового поля.
К слову: в консоли система координат начинается с верхнего левого угла (точка 0;0 ) и есть ограничение (которое тоже можно настроить) в 80 по х и 80 по y. Если мы достигаем 80 по х, то мы крашимся (то есть приложение ломается), а если 80 по у, то просто увеличивается размер поля (настроить эти ограничения можно нажав пкм по консоли и выбрав свойства).
5-й:
public static void WriteToLastPosition(PlayerState player, string TEXT) { Console.CursorLeft = player.LastPosition.X; Console.CursorTop = player.LastPosition.Y; Console.Write(TEXT); }
Тут мы просто печатаем текст в предыдущую позицию (закрашиваем её).
Четвёртого метода нету, так как мы ещё не объявили структуру снарядов.
Давайте же сейчас поговорим о ней.
Структура 'Снаряды'(ShotState)
Эта структура должна описывать движение снарядов и 'забывать' (закрашивать) путь снаряда.
Каждый снаряд должен иметь направление, начальную позицию и урон.
Т.е. её поля будут следующие:
private Position Shot_position { get; set; } private int dir { get; set; } private int ID_Player { get; set; } private int damage { get; set; } private List<int> x_way { get; set; } private List<int> y_way { get; set; }
Экземпляр класса позиции — это текущая позиция снаряда, dir — это направление движения снаряда, ID_Player — ID игрока пустившего снаряд, damage — урон этого снаряда, x_way — движение по Х, y_way — движение снаряда по Y.
Вот все методы и поля (описание их ниже)
/// <summary> /// Забываем путь(закрашиваем его) /// </summary> /// <param name="shot">Снаряд</param> public static void ForgetTheWay(ShotState shot) { int[] x = ShotState.x_way_array(shot); int[] y = ShotState.y_way_array(shot); switch (shot.dir) { case 0: { for (int i = 0; i < x.Length - 1; i++) { Console.CursorTop = y[0]; Console.CursorLeft = x[i]; Console.Write("0"); } } break; case 90: { for (int i = 0; i < y.Length - 1; i++) { Console.CursorLeft = x[0]; Console.CursorTop = y[i]; Console.Write("0"); } } break; case 180: { for (int i = 0; i < x.Length - 1; i++) { Console.CursorLeft = x[i]; Console.CursorTop = y[0]; Console.Write("0"); } } break; case 270: { for (int i = 0; i < y.Length - 1; i++) { Console.CursorTop = y[i]; Console.CursorLeft = x[0]; Console.Write("0"); } } break; } } /// <summary> /// Конструктор снарядов /// </summary> /// <param name="positionShot">Позиция выстрела</param> /// <param name="dir_">Куда летим</param> /// <param name="ID_Player">От кого летим</param> /// <param name="dam">Какой урон</param> public ShotState(Position positionShot, int dir_, int ID_Player_, int dam) { Shot_position = positionShot; dir = dir_; ID_Player = ID_Player_; damage = dam; x_way = new List<int>(); y_way = new List<int>(); x_way.Add(Shot_position.X); y_way.Add(Shot_position.Y); } public static string To_string(ShotState shot) { return shot.ID_Player.ToString() + ":" + shot.Shot_position.X + ":" + shot.Shot_position.Y + ":" + shot.dir + ":" + shot.damage; } private Position Shot_position { get; set; } private int dir { get; set; } private int ID_Player { get; set; } private int damage { get; set; } private List<int> x_way { get; set; } private List<int> y_way { get; set; } private static int[] x_way_array(ShotState shot) { return shot.x_way.ToArray(); } private static int[] y_way_array(ShotState shot) { return shot.y_way.ToArray(); } public static void NewPosition(ShotState shot, int X, int Y) { shot.Shot_position.X = X; shot.Shot_position.Y = Y; shot.x_way.Add(shot.Shot_position.X); shot.y_way.Add(shot.Shot_position.Y); } public static void WriteShot(ShotState shot) { Console.CursorLeft = shot.Shot_position.X; Console.CursorTop = shot.Shot_position.Y; Console.Write("0"); } public static void Position_plus_plus(ShotState shot) { switch (shot.dir) { case 0: { shot.Shot_position.X += 1; } break; case 90: { shot.Shot_position.Y += 1; } break; case 180: { shot.Shot_position.X -= 1; } break; case 270: { shot.Shot_position.Y -= 1; } break; } Console.ForegroundColor = ConsoleColor.White; Console.CursorLeft = shot.Shot_position.X; Console.CursorTop = shot.Shot_position.Y; Console.Write("0"); shot.x_way.Add(shot.Shot_position.X); shot.y_way.Add(shot.Shot_position.Y); } public static Position ReturnShotPosition(ShotState shot) { return shot.Shot_position; } public static int ReturnDamage(ShotState shot) { return shot.damage; }
Первый метод — мы забываем путь в консоли (т.е. закрашиваем его), через коллекции с этим путём.
Следующий — это конструктор (то есть основной метод, который настраивает наш экземпляр структуры).
Третий метод — выводит всю информацию в текстовом виде и используется только при отправке
на сервер.
Дальнейшие методы печатают/возвращают некоторые поля для дальнейшего использования.
Структура 'Стена (WallState)'
Все поля этой структуры а так же методы для представления стены и нанесения ей урона.
Вот её поля и методы:
private Position Wall_block { get; set; } private int HP { get; set; } private static void hp_minus(WallState wall ,int damage) { wall.HP -= damage; } /// <summary> /// Создаём блок стены /// </summary> /// <param name="bloc">Координаты блока</param> /// <param name="hp">Здоровье</param> public WallState(Position bloc, int hp) { Wall_block = bloc; HP = hp; } public static bool Return_hit_or_not(Position pos, int damage) { if (pos.X <= 0 || pos.Y <= 0 || pos.X >= Width || pos.Y >= Height) { return true; } // // // for (int i = 0; i < Walls.Count; i++) { if ((Walls[i].Wall_block.X == pos.X) && (Walls[i].Wall_block.Y == pos.Y)) { WallState.hp_minus(Walls[i], damage); if (Walls[i].HP <= 0) { Console.CursorLeft = pos.X; Console.CursorTop = pos.Y; Console.ForegroundColor = ConsoleColor.Black; Walls.RemoveAt(i); Console.Write("0"); Console.ForegroundColor = ConsoleColor.White; } return true; } } return false; }
Так вот. Подведём некий итог.
Для чего нам метод 'Return_hit_or_not'? Он возвращает было ли касание какой-либо координаты, какого-либо объекта, и наносит ему урон. Метод 'CreateShot' создаёт снаряд из конструктора.
Взаимодействие структур
В нашем основном потоке существуют два параллельных потока (Task), будем отталкиваться от них.
Task tasc = new Task(() => { Event_listener(); }); Task task = new Task(() => { To_key(); }); tasc.Start(); task.Start(); Task.WaitAll(task, tasc);
Что за потоки? Первый принимает данные от сервера и обрабатывает их, а второй отправляет данные на сервер.
Так вот, нам нужно слушать сервер (то есть принимать данные от него) и обрабатывать принятые данные, а так же выполнять операции по переданным нам данным.
Любые принятые данные от сервера в нашем проекте представляют собой объект события, где аргументы (как и имя события) разделяются символом ':', то есть на выходе мы имеем такую схему: EventName:Arg1:Arg2:Arg3:...ArgN.
Так вот, существуют так же два вида событий (так как больше и не нужно) и взаимодействия с элементами структур в нашем проекте, а именно движение танка и создание+движение снаряда.
Но мы всё ещё не знаем как принимать эти данные, не то что обрабатывать, поэтому мы лезем в прекраснейший сайт (ссылка внизу статьи) и читаем про сеть и сокеты (нам нужен именно UDP), берём их код и переделываем под себя (не забываем что нужно именно вникнуть в информацию на этом сайте, а не бездумно копировать), на выходе получается вот такой код:
static void Event_listener() { // Создаем UdpClient для чтения входящих данных UdpClient receivingUdpClient = new UdpClient(localPort); IPEndPoint RemoteIpEndPoint = null; try { /*th - переменная отвечающая за сброс цикла (дабы завершить задачи и закрыть приложение)*/ while (th) { // Ожидание дейтаграммы byte[] receiveBytes = receivingUdpClient.Receive(ref RemoteIpEndPoint); // Преобразуем данные string returnData = Encoding.UTF8.GetString(receiveBytes); //TYPEEVENT:ARG // это наш формать приходящих данных string[] data = returnData.Split(':').ToArray<string>(); // об этом ниже Task t = new Task(() =>{ Event_work(data); }); t.Start(); // так же как и об этом } } catch (Exception ex) { Console.Clear(); Console.WriteLine("Возникло исключение: " + ex.ToString() + "\n " + ex.Message); th = false;//сбрасываем циклы приёма-передачи данных на сервер } }
Тут мы видим прекрасно готовый код, который выполняет ровно то о чём мы говорили выше, то есть методом '.Split(:)' мы делим текст на массив строк, далее методом '.ToArray()' собираем этот массив в переменную 'data', после чего мы создаём новый поток (асинхронный, т.е. выполняется независимо от выполнения задачи в этом метода) так же как и методе «Main», описываем его и запускаем (методом '.Start()').
Небольшое пояснение в виде картинки с кодом (я использовала для теста этой задумки этот код), этот код не относится к проекту, он просто создан для теста того кода (как аналогичный) и решения одной очень важной задачи: «Возможно ли выполнять действия независимо от кода в основном метода». Спойлер: да!
static void Main(string[] args) { //int id = 0; //Task tasc = new Task(() => { SetBrightness(); }); //Task task = new Task(() => { SetBrightness(); }); //tasc.Start(); //task.Start(); //Task.WaitAll(task, tasc); for (int i = 0; i < 5; i++) { Task tasc = new Task(() => { SetBrightness(); }); tasc.Start(); //Thread.Sleep(5); } Console.WriteLine("It's end"); Console.Read(); } public static void SetBrightness() { for (int i = 0; i < 7; i++) { int id = i; switch (id) { case 1: { Console.ForegroundColor = ConsoleColor.White; } break; case 2: { Console.ForegroundColor = ConsoleColor.Yellow; } break; case 3: { Console.ForegroundColor = ConsoleColor.Cyan; } break; case 4: { Console.ForegroundColor = ConsoleColor.Magenta; } break; case 5: { Console.ForegroundColor = ConsoleColor.Green; } break; case 6: { Console.ForegroundColor = ConsoleColor.Blue; } break; } Console.WriteLine("ТЕСТ"); } }
И вот его работа:

Четыре запущенных потока (один из которых почти выполнил свою работу):

Перемещаемся дальше, а точнее в метод выполняемый потоком:
static void Event_work(string[] Event) { // принимаем мы массив EventType в нём будет первым элементом // остальные элементы (а именно те что ниже) идентичны для каждого события // НО если событие выстрел, то добавляется урон (шестой элемент массива) int ID = int.Parse(Event[1]), X = int.Parse(Event[2]), Y = int.Parse(Event[3]), DIR = int.Parse(Event[4]); switch (Event[0]) { case "movetank": { Print_tanks(ID, X, Y, DIR); } break; case "createshot": { ShotState shot = new ShotState(new Position(X, Y), DIR, ID, int.Parse(Event[4])); MoveShot(shot); } break; default: { return; } break; } }
Теперь начинает вырисовываться схема описания, если наш тип события 'movetank' то взаимодействуют только следующие элементы: 'Стены' и 'Танк'.
Но если тип события 'createshot', то взаимодействует, буквально, всё.
Если выстрел коснулся стены — то он отнял ей здоровье, если выстрел коснулся игрока — то он отнял ему здоровье, если выстрел просто улетел — то он пропал и очистился.
Если же у нас прочее событие — то мы выходим из этого метода, всё кажется простым.
Но не всё так просто, самый сок начинается если мы копаем глубже, а точнее, в вызываемые методы.
Из названия этих методов понятно, что первый — это движение танка, то есть это отрисовка и перемещение коллайдеров, а второй — создание и запуск выстрела.
Метод рисующий танки:
static void Print_tanks(int id, int x, int y, int dir) { PlayerState player = Players[id]; Console.ForegroundColor = ConsoleColor.Black; PlayerState.WriteToLastPosition(player, "000\n000\n000"); /* 000 000 000 */ switch (id) { case 0: { Console.ForegroundColor = ConsoleColor.White; } break; case 1: { Console.ForegroundColor = ConsoleColor.Yellow; } break; case 2: { Console.ForegroundColor = ConsoleColor.Cyan; } break; case 3: { Console.ForegroundColor = ConsoleColor.Magenta; } break; case 4: { Console.ForegroundColor = ConsoleColor.Green; } break; case 5: { Console.ForegroundColor = ConsoleColor.Blue; } break; } PlayerState.NewPosition(player, x, y); switch (dir) { case 270: case 90: { Console.CursorLeft = x; Console.CursorTop = y; Console.Write("0 0\n000\n0 0"); } break; /* 0 0 000 0 0 */ case 180: case 0: { Console.CursorLeft = x; Console.CursorTop = y; Console.Write("000\n 0 \n000"); } break; /* 000 0 000 */ } }
И последний метод (для снаряда (создаёт и двигает его)):
private static void MoveShot(ShotState shot) { ShotState Shot = shot; while ((!PlayerState.Return_hit_or_not(ShotState.ReturnShotPosition(Shot), ShotState.ReturnDamage(Shot))) && (!WallState.Return_hit_or_not(ShotState.ReturnShotPosition(Shot), ShotState.ReturnDamage(Shot)))) { // если достигли координат стен - то вернёт фалс и будет брейк ShotState.Position_plus_plus(Shot); } Console.ForegroundColor = ConsoleColor.Black;//забываем путь полёта пули(закрашиваем его) ShotState.ForgetTheWay(Shot); }
Вот это и весь наш приём и обработка событий, теперь перейдём к их созданию (методу создателю и отправщику их на сервер)
Создаём события (To_key())
Вот целый метод создающий события, меняющий наши координаты и отправляющий всё это на сервер (описание ниже):
static void To_key() { //Приём нажатой клавиши PlayerState MyTank = Players[MY_ID]; System.Threading.Timer time = new System.Threading.Timer(new TimerCallback(from_to_key), null, 0, 10); while (true) { Console.CursorTop = 90; Console.CursorLeft = 90; switch (Console.ReadKey().Key) { case ConsoleKey.Escape: { time.Dispose(); th = false; break; } break; //пробел case ConsoleKey.Spacebar: { if (for_shot) { //"createshot" var shot = PlayerState.CreateShot(Players[MY_ID], PlayerState.NewPosition_X(MyTank, '\0'), 3); MessageToServer("createshot:" + PlayerState.To_string(MyTank) + ":3");// дамаг - 3 var thr = new Task(() => { MoveShot(shot); }); for_key = false;//откат кнопок for_shot = false;//откат выстрела } } break; case ConsoleKey.LeftArrow: { if (for_key) { PlayerState.NewPosition_X(MyTank, '-'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; case ConsoleKey.UpArrow: { if (for_key) { PlayerState.NewPosition_Y(MyTank, '-'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; case ConsoleKey.RightArrow: { if (for_key) { PlayerState.NewPosition_X(MyTank, '+'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; case ConsoleKey.DownArrow: { if (for_key) { PlayerState.NewPosition_Y(MyTank, '+'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; case ConsoleKey.PrintScreen: { } break; case ConsoleKey.A: { if (for_key) { PlayerState.NewPosition_X(MyTank, '-'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; case ConsoleKey.D: { if (for_key) { PlayerState.NewPosition_X(MyTank, '+'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; // Аналог нажатия на пробел case ConsoleKey.E: { if (for_shot) { for_key = false; for_shot = false; } } break; // Аналог нажатия на пробел, но спец выстрел case ConsoleKey.Q: break; case ConsoleKey.S: { if (for_key) { PlayerState.NewPosition_Y(MyTank, '+'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; case ConsoleKey.W: { if (for_key) { PlayerState.NewPosition_Y(MyTank, '-'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; case ConsoleKey.NumPad2: { if (for_key) { PlayerState.NewPosition_Y(MyTank, '+'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; case ConsoleKey.NumPad4: { if (for_key) { PlayerState.NewPosition_X(MyTank, '-'); MessageToServer(PlayerState.To_string(MyTank)); } } break; case ConsoleKey.NumPad6: { if (for_key) { PlayerState.NewPosition_X(MyTank, '+'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; //нажатие на пробел case ConsoleKey.NumPad7: { if (for_shot) { for_key = false; for_shot = false; } } break; case ConsoleKey.NumPad8: { if (for_key) { PlayerState.NewPosition_Y(MyTank, '-'); MessageToServer("movetank:" + PlayerState.To_string(MyTank)); for_key = false; } } break; // Аналог нажатия на пробел но спец выстрел case ConsoleKey.NumPad9: break; default: break; } } }
Тут мы используем один и тот же метод 'MessageToServer', его цель — отправить данные на сервер.
И методы 'NewPosition_Y' и 'NewPosition_X', которые назначают нашему танку новую позицию.
(в кейсах — используемые клавиши, я пользуюсь в основном стрелками и пробелом — вы можете выбрать свой вариант и скопипастить код из кейса '.Spase' в лучший для вас вариант (или же написать его (указать клавишу) самому))
И вот последний метод из взаимодействия событий клиента-сервера, сама отправка на сервер:
static void MessageToServer(string data) { /* Тут будет отправка сообщения на сервер */ // Создаем UdpClient UdpClient sender = new UdpClient(); // Создаем endPoint по информации об удаленном хосте IPEndPoint endPoint = new IPEndPoint(remoteIPAddress, remotePort); try { // Преобразуем данные в массив байтов byte[] bytes = Encoding.UTF8.GetBytes(data); // Отправляем данные sender.Send(bytes, bytes.Length, endPoint); } catch (Exception ex) { Console.WriteLine("Возникло исключение: " + ex.ToString() + "\n " + ex.Message); th = false; } finally { // Закрыть соединение sender.Close(); } }
Теперь самое главное — перезарядка движения и выстрела (перезарядка движения как античит, и небольшое время простоя для обработки на других машинах).
Этим занимается таймер в методе 'To_key()', а точнее 'System.Threading.Timer time = new System.Threading.Timer(from_to_key(), null, 0, 10);'.
В этой строчке кода мы создаём новый таймер, назначаем ему метод для управления ('from_to_key()'), указываем что не передаём туда ничего 'null', время с которого начнётся счёт таймера '0'( ноль миллисекунд (1000ms(миллисекунда) — 1s (секунда)) и интервал вызова метода (в миллисекундах) '10' (кстати метод 'To_key()' полностью настроен на перезарядку (это выражается в условиях в кейсах, они связанны с полями в классе Program)).
Выглядит этот метод так:
private static void from_to_key(object ob) { for_key = true; cooldown--; if (cooldown <= 0) { for_shot = true; cooldown = 10; } }
Где 'cooldown' — это перезарядка (выстрела).
И всё же большинство элементов в этом проекте — это поля:
private static IPAddress remoteIPAddress;// ай пи private static int remotePort;//порт private static int localPort = 1011;//локальный порт static List<PlayerState> Players = new List<PlayerState>();// игроки static List<WallState> Walls = new List<WallState>();// //-------------------------------- static string host = "localhost"; //-------------------------------- /* Тут должно быть получение координат с сервера и назначение их нашему танчику */ static int Width;/* Высота и ширина игрового поля */ static int Height; static bool for_key = false; static bool for_shot = false; static int cooldown = 10; static int MY_ID = 0; static bool th = true;//для завершения потока
Наконец конец
Вот и конец данной статьи с описанием кода проекта танчиков, мы смогли реализовать почти всё, кроме одного — загрузки данных с сервера (стены, танки(игроки)) и назначения нашему танчику начальных координат. Этим мы займёмся в следующей статье, где будем уже касаться сервера.
Ссылки, упомянутые в статье:
Мой репозиторий
Классные программисты, который мне помогли написать этот код и научили новому: Habra-Mikhail, norver.
Спасибо за прочтение данной статьи, если я в чём-то на ваш взгляд оказалась не права — пишите в комментарии и мы вместе это изменим.
Так же прошу обращать внимание на комментарии, так как в них обсуждаются улучшения как проекта, так и кода. Если захотите помочь в переводе книги — прошу написать мне в сообщения или на почту: koito_tyan@mail.ru.
Да начнётся игра!
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Считаете ли вы нужным перевод второго издания книги RustBook
31.43%Да, я хочу увидеть что там нового11
11.43%Да, я хочу вступить в команду по переводу этой книги4
25.71%Нет, я тут только для c# и жду на нём и сервер!9
14.29%Нет, без объяснений5
17.14%Я останусь нейтральным и если захочу — выражу свою позицию в комментариях6
Проголосовали 35 пользователей. Воздержались 19 пользователей.
