Пишем игру Гонки на бумаге, C# WPF

    Предистория

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

    Правила игры

    Можно прочитать правила в Википедии, прочитав это и ничего не зная, я бы не понял ). Главное правило игры - правила перемещения машинки. Правило 2 - не можешь сделать ход попав в дорогу - ты проиграл.

    Ход 1: машинка стоит на месте и может ехать в точки, которые расположены вокруг той точки в которую направлен вектор движения машинки. В начале игры вектор нулевой, никуда не направлен, значит мы можем пойти в точки вокруг машинки. Делаем ход в точку над машинкой.

    Ход 2: Машинка передвигается на одну клетку вертикально и ноль клеток горизонтально. Это и есть текущая скорость машинки (1 по вертикали и 0 по горизонтали). Вектор скорости направлен на клетку сверху от машинки. Вокруг этой точки машинка может выбрать точки для выполнения хода. Сделаем ход в левую-верхнюю точку.

    Ход 3: Машинка передвигается на две клетки вертикально и одну горизонтально. Вектор движения машинки соответственно изменяется.

    Ход 4: Машинка передвигается на 3 клетки вертикально.

    Теперь машинка движется со скоростью 3 клетки вверх и 0 клеток влево/вправо. Машинка имеет скорость и не может за один ход полностью остановиться или сделать резкий поворот в сторону. В данном случае машинка может:

    1. Ускориться если выбрать верхние (дальние от машинки) узлы. Если ход в верхнюю-левую точку, то скорость будет 4 вверх и 1 влево. Если ход в верхнюю-правую точку, то скорость будет 4 вверх и 1 вправо.

    2. Не изменять скорость если выбрать средний ряд. В этом случае появляется возможность поворота в сторону под более большим углом.

    3. Понизить скорость выбрав нижний (ближайший к машинке) ряд. Стоит выбирать при приближению к резкому повороту.

    План разработки

    1. Генератор случайной трассы. Трасса должна уметь поворачивать в любом направлении на любое число градусов. Элементы трассы должны быть произвольной длины и ширины.

    2. Положение пользователя на игровой карте должно быть показано машинкой. Машинка должна уметь поворачиваться в сторону движения. Игровая карта может быть любого размера.

    3. На игровой карте нужно различать следующие типы полей:
      3.1 поле, куда машинка может сделать безопасный ход
      3.2 поле, куда машинка может сделать ход, но это будет выход за трассу
      3.3 поле, принадлежащее дороге
      3.4 поле, не принадлежащее дороге
      3.5 текущее поле с машинкой

    4. Показывать отрезки пройденного пути на игровой карте

    5. Возможность начать новую игру сначала

    6. Отображение текущей скорости

    7. Возможность изменения настроек программы через файл конфигурации. Список необходимых значений в файле конфигурации:
      7.1 Ширина фрагмента дороги
      7.2 Количество фрагментов дороги для генерации новой трассы
      7.3 Максимальный угол поворота дороги влево
      7.4 Максимальный угол поворота дороги направо
      7.5 Минимальная длина участка дороги
      7.6 Максимальная длина участка дороги
      7.7 Положение машинки на игровой карте

    8. Сделать генерацию пейзажа вокруг дороги для приятного юзабилити

    9. Показывать дорожные знаки предупреждающие об опасном повороте

    Подготовка

    Для разработки программы выбран язык программирования C#. Целевая рабочая среда .NET 5.0. Графическая среда WPF. Среда разработки Visual Studio 2019.

    Генератор случайной трассы

    Самы простой способ нарисовать трассу - воспользоваться фигурой Polyline. Но, встал вопрос, как рассчитывать нахождение принадлежности точки этой фигуре и от этого варианта пришлось отказаться. Самый надежный вариант - это представить трассу как массив прямоугольников. Для скругления поворотов трассы нужно дополнительно строить окружности в местах пересечения элементов трассы. Диаметр окружности - это ширина элемента дороги.

    Для каждого сгенерированного элемента трассы нужно хранить:

    1. Точку начала (X и Y)

    2. Точку конца (X и Y)

    3. Ширину элемента трассы (в данной реализации ширина трассы одинаковая для всех элементов, задел на будущее)

    4. Длину элемента трассы

    5. Угол наклона элемента трассы к оси oX

    Положение пользователя на игровой карте

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

    /// <summary>
    /// Текущий сдвиг карты по осям
    /// </summary>
    int _deltaX, _deltaY = 0;

    Изображение машинки сделано в виде машины с четко выделенным задом и передом. Чтобы было понятно пользователю текущее направление движения машинки нужно поворачивать машинку в сторону направления движения.

    Каждый выполненный ход сохраняем в списке _pathList - список отрезков пройденного пути.

    /// <summary>
    /// Список отрезков пройденного пути
    /// </summary>
    List<PathElement> _pathList = new List<PathElement>();

    Отрезок пройденного пути должен хранить следующие данные:

    1. Точка начала отрезка (X и Y)

    2. Точка конца отрезка (X и Y)

    3. Смещение машинки относительно карты в момент выполнения хода

    Зная точку начала и конца отрезка можно вычислить угол наклона отрезка к оси oX.
    AC - изменение координаты X от начала до конца отрезка
    BC - изменение координаты Y от начала до конца отрезка

    \tan{(\angle ABC)} = \frac{AC}{BC}\angle ABC = \arctan{(\frac{AC}{BC})} * \frac{180}{\pi}
    /// <summary>
    /// Текущий угол наклона пути
    /// </summary>
    public double Angle
    {
         get
         {
              if (ToY == FromY && ToX > FromX) return 90;
              if (ToY == FromY && ToX < FromX) return -90;
              if (ToX == FromX && ToY > FromY) return 180;
              if (ToX == FromX && ToY < FromY) return 0;
    
              if (ToY <= FromY && ToX >= FromX) return -Math.Atan((ToX - FromX) / (ToY - FromY)) * 180 / Math.PI;
              if (ToY <= FromY && ToX <= FromX) return - Math.Atan((ToX - FromX) / (ToY - FromY)) * 180 / Math.PI;
    
              if (ToY >= FromY && ToX <= FromX) return Math.Atan((ToY - FromY) / (ToX - FromX)) * 180 / Math.PI - 90;
              if (ToY >= FromY && ToX >= FromX) return Math.Atan((ToY - FromY) / (ToX - FromX)) * 180 / Math.PI + 90;
    
              return 0;
         }
    }

    Типы полей игровой карты

    Для выполнения хода на игровой карте расположены узлы - кнопки (Button) на расстоянии 20 пикселей друг от друга, 39 рядов по 39 кнопок. Таким образом достигается функционал выполнения нового хода.

    Было сказано, что при выполнении хода машинка остается на месте, а элементы карты меняют свое положение. Это значит, что при каждом выполнении хода необходимо проверять все узлы карты чтобы понять куда попадает в данный момент данный узел:

    1. поле, куда машинка может сделать безопасный ход

    2. поле, куда машинка может сделать ход, но это будет выход за трассу

    3. поле, принадлежащее дороге

    4. поле, не принадлежащее дороге

    5. текущее поле с машинкой


    При проверке нужно найти поля в которые машинка может сделать ход. Для этого учитывается текущая скорость по оси X и по оси Y.

    Проверка нахождения точки дороге:
    Дорога представляет собой массив прямоугольников (элементы дороги) и кругов (стыки элементов дорог).
    Проверка принадлежности точки кругу рассчитывается по формуле:

    (X \text{-} X_0)^2 + (Y \text{-} Y_0)^2 <= R^2

    Проверка принадлежности точки прямоугольнику немного сложнее:
    Из точки C (это точка, которую проверяем на принадлежность прямоугольнику) опустим перпендикуляр на отрезок AB (точки A и B - точки начала и конца элемента дороги соответственно). Данный перпендикуляр - это высота треугольника. Поиск факта принадлежности точки прямоугольнику сводится к проверке:

    h <= \frac{\text{Ширина дороги}}{2}


    Существует 3 различные ситуации где может находиться точка по отношению к прямоугольнику:

    1. h > (ширина элемента дороги / 2) - точка не принадлежит прямоугольнику

    2. h <= (ширина элемента дороги / 2) - точка принадлежит прямоугольнику если ABC < 90° и CAB < 90°

    3. высота h опускается не на отрезок AB, а на его продолжение. Хотя и длина высоты удовлетворяет нашей формуле, но если ABC > 90° или CAB > 90°, то точка не принадлежит прямоугольнику.

    Формула нахождения площади треугольника зная координаты его вершин:

    S(ABC) = \frac{|(X_A \text{-} X_C)*(Y_B \text{-} Y_C) \text{-} (X_B \text{-} X_C)*(Y_A \text{-} Y_C)|}{2}

    С другой стороны, формула нахождения площади треугольника зная его высоту:

    S(ABC) = \frac{h * AB}{2}

    Данных двух формул достаточно для расчета высоты треугольника (а еще Вы могли не заморачиваться с площадью и рассчитать проще используя:

    \sin( \angle ABC) = \frac{h}{BC}

    но я это понял на момент написания статьи)


    Формула нахождения длины отрезка по его координатам:

    AB = \sqrt{(X_A \text{-} X_B)^2 + (Y_A \text{-} Y_B)^2}AC = \sqrt{(X_A \text{-} X_C)^2 + (Y_A \text{-} Y_C)^2}BC = \sqrt{(X_B \text{-} X_C)^2 + (Y_B \text{-} Y_C)^2}

    Формула нахождения углов треугольника зная длины сторон треугольника:

    \angle ABC = \arccos{(\frac{AB^2 + BC^2 - AC^2}{2*AB*BC})} * \frac{180}{\pi}\angle CAB = \arccos{(\frac{AB^2 + AC^2 - BC^2}{2*AB*AC})} * \frac{180}{\pi}

    Отрезки пройденного пути

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

    1. Точка начала отрезка (X и Y)

    2. Точка конца отрезка (X и Y)

    3. Смещение машинки относительно карты в момент выполнения хода

    4. Угол наклона к оси oX рассчитывается автоматически

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

    Возможность начать новую игру сначала

    Для реализации данной фичи сделаем в главном меню программы элемент типа MenuItem, реализуем событие мыши Click, пропишем очиску всех элементов игровой карты, очистку сохраненных элементов списков пройденного пути, элементов дороги, обнуление переменных сдвига карты и текущей скорости машинки.

    Отображение текущей скорости

    Физика прототипа игры такова, что мы имеем две скорости: скорость по оси oX, скорость по оси oY.

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

    Возможность изменения настроек программы через файл конфигурации

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

    1. Ширина фрагмента дороги

    2. Количество фрагментов дороги для генерации новой трассы

    3. Максимальный угол поворота дороги влево

    4. Максимальный угол поворота дороги направо

    5. Минимальная длина участка дороги

    6. Максимальная длина участка дороги

    7. Положение машинки на игровой карте

    {
      "RoadWidth": "100",
      "RoadElementsCount": "20",
      "MinAngle": "-60",
      "MaxAngle": "60",
      "MinRoadLength": "100",
      "MaxRoadLength": "200",
      "UserPosition": {
        "X": "400",
        "Y": "400"
      }
    }

    В зависимости от данных настроек игровое поле выглядит по-разному:

    Генерация пейзажа, улучшение GUI

    Для более приятного впечатления к игре нужно добавить как можно большее количество различных элементов (в рамках разумности конечно):

    1. Заполнить игровое поле "Землей" - изображение, заполняющее все поле

    2. В момент выполнения хода мы каждый раз проверяем тип каждого узла игровой карты. В данный метод кода можно добавить генерацию "Елок" в случае если поле данного узла не принадлежит дороге. Чтобы елок не было слишком много, ограничим шанс появления елки в 20%.

      var random = new Random();
      var elkaChance = random.Next(1, 101);
      if (elkaChance < 20)
      {
      		// рисуем елку
      }

    3. Чтобы дорожное полотно было похоже на дорожное полотно можно нанести прерывистую разметку проходящую посередине элементов дорог, как раз соединяя точку начала и конца каждого элемента дороги. Чтобы данная разметка была более естесственной, воспользуемся фигурой Polyline передав фигуре коллекцию точек элементов дороги.

    var polyLinePointCollection = new PointCollection();
    foreach (var roadElement in _roadElements)
    {
        if (polyLinePointCollection.Count == 0) polyLinePointCollection.Add(new Point(roadElement.StartPoint.X + 5 + _deltaX, roadElement.StartPoint.Y + _deltaY));
        polyLinePointCollection.Add(new Point(roadElement.EndPoint.X + 5 + _deltaX, roadElement.EndPoint.Y + _deltaY));
    }
    
    var polyLine = new Polyline()
    {
        Points = polyLinePointCollection,
        Stroke = Brushes.White,
        StrokeDashArray = new DoubleCollection() { 6, 4 },
        StrokeThickness = 2
    };

    Дорожные знаки опасного поворота

    Зная всю информацию об сгенерированных элементах дороги можно сравнивать каждый элемент дороги с предыдущим элементом и высчитать разницу углов поворота фигур. Если полученное значение > 70°, то покажем знак опасности в точке начала элемента дороги.

    Angle_\text{curr} \text{-} Angle_\text{prev} > 70°

    Бывают случаи когда один угол +178°, а второй -178°. Реальная разница между данными углами всего 4°. Для решения данной задачи нужно добавить условие, что угол будет опасным при разнице углов меньше 290°. Формула изменится к данному виду:

    70° \leq Angle_\text{curr} \text{-} Angle_\text{prev} \leq 290°

    Код программы

    В моем GitHub

    Комментарии 7

      +3
      Чтобы узнать нахождение точки внутри прямоугольника не обязательно вычислять углы и площади, достаточно скалярного произведения векторов
        0
        Так, а там же основная проблема, что у того игрока, который ходит первым, серьезное преимущество (он может перекрыть дорогу другому игроку). А одновременно несколько игроков ходить не могут, поскольку в случае попадания в одну точку, непонятно кому отдавать предпочтение…
          0
          В данный момент игра на одного человека сделана. Добавить функционал на несколько игроков не проблема. Нужно только хранить сдвиг карты для каждого игрока и при выполнении хода карта должна сдвигаться для текущего игрока.

          По правилам игры, игроки ходят по очереди. Если игрок попадает на поле сдругим игроком, то происходит авария. Далее, согласно разным правилам, либо игрок врезавшийся игрок выбывает из игры, либо его скорость становится 0 и пропускает 1 ход.
          0
          Спасибо, интересная статья! Хотел только заметить, что для вычисления угла наклона пути лучше использовать Math.Atan2 — он не требует обработки специальных случаев и должен сократить ваши 9 строчек до одной.
            0

            Спасибо !

            0
            Давно думал о создании этой игры в цифре, но исследования показали, что она слишком простая — почти невозможно ошибиться. В вашем варианте тоже играть чрезмерно легко. Возможно, наличие противника, который пытается тебя обогнать, решило бы проблему, но пока что кажется, что это как крестики-нолики — в каждый момент есть единственный оптимальный ход, и всегда выбираешь его.
              0
              Очень интересная реализацию. Спасибо за статью
              Посмотрел исходники. Жаль, игровая логика просто гвоздями прибита к WPF
              Спортировать под что-то другое, будет означать серьезный рефакторинг содержимого файла MainWindow.xaml.cs

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

              Самое читаемое