Предыдущие части: Часть 0, Часть 1

4.4 Создаем визуальное оформление

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

//View
protected override void Initialize()
{
  base.Initialize();
  _graphics.IsFullScreen = false;
  _graphics.PreferredBackBufferWidth = 1024;
  _graphics.PreferredBackBufferHeight = 768;
  _graphics.ApplyChanges();
}

Обратите внимание, что для того, чтобы изменения вступили в силу, нужно вызвать у объекта _graphics метод ApplyChanges. Без этого метода волшебства не случится.

Следующим шагом сделаем машинку. Чтобы не иметь проблем с правообладателями и не тратить время, нарисуем ее сами. Я выбрал такой дизайн:

Если вы узнали эту картинку, значит, вам пора считать свой пенсионный стаж. Это тоже заглушка, в конце мы достанет нормальные спрайты машинок, но визуально такое решение выглядит намного приятнее, чем раньше. Добавляем ее в ресурсы на место White Placeholder.

Немного избавимся от хардкода и добавим механику автоматической генерации ключей объекта в методе Initialize нашей Модели, так как все-таки подразумевается, что у нас будет не один объект на карте, а много.

public class GameCycle : IGameplayModel
{
    public event EventHandler<GameplayEventArgs> Updated = delegate { };

    private int _currentId; //Добавляем поле свободного ключа для объекта

    public int PlayerId { get; set; }
    public Dictionary<int, IObject> Objects { get; set; }
   
    public void Initialize()
    {       
        Objects = new Dictionary<int, IObject>();
       _currentId = 1; //Инициализируем свободный ключ
       Car player = new Car();
       player.Pos = new Vector2 (512-90, 500);
       player.ImageId = 1;
       player.Speed = new Vector2 (0, 0);
       //Добавляем объект в словарь с использованием нового поля
       //и осуществляем инкрементацию ключа
       Objects.Add (_currentId, player);
       PlayerId = _currentId;
       _currentId++;
    }

Магические числа в размещении игрока new Vector2 (512-90, 500) появились для размещения машинки по центру экрана. 90, так как ширина спрайта у меня равна 180. Позднее от хардкода здесь и на других участках нужно будет избавиться. Выглядеть это будет следующим образом (чтобы получить такой же цвет фона во View в методе Clear объекта GraphicsDevice нужно поставить DarkSeaGreen):

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

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

Для начала обращу ваше внимание на следующее: логическая часть и отрисовка четко разделены (чего я с самого начала и добивался) — неважно, как мы меняем координату нашей машинки внутри логики — чтобы она появилась на экране нужно дать отдельную команду _spriteBatch.Draw, в которой указывается, что мы рисуем и где. Иными словами, координаты объекта и координаты, где мы его нарисуем на экране, для программы представляют собой разные сущности. Они могут совпадать, а могут не совпадать. Чтобы поменять поле зрения достаточно сделать так, чтобы они не совпадали. Следующий вопрос — на сколько они не должны совпадать? Так как наша камера должна двигаться так, чтобы машинка у нас на экране всегда была неподвижной, то, очевидно, координаты, в которых мы рисуем объекты, должны смещаться относительно своего реального положения на величину, на которую сместилась машинка игрока относительно своего предыдущего положения.

Итак, объявим в классе GameCycleView новое приватное поле _visualShift, которое и будет показывать, на сколько нужно сместить объект при отрисовке, а в его методе Draw вычтем значение этого поля из позиции отрисовки:

public class GameCycleView : Game, IGameplayView
{
	//<.....................>
  private Vector2 _visualShift = new Vector2(0, 0);
  //<.....................>
  protected override void Draw(GameTime gameTime)
  {
    GraphicsDevice.Clear(Color.DarkSeaGreen);
    base.Draw(gameTime);
    _spriteBatch.Begin();
    
    foreach (var o in _objects.Values)
    {
    	_spriteBatch.Draw(
      _textures[o.ImageId], 
      o.Pos - _visualShift/*смещение позиции отрисовки*/,
      Color.White
      );
    }
    _spriteBatch.End();
  }    
}

Таким образом, мы вычитаем из позиции объекта вектор _visualShift, в который будем каждый цикл записывать сдвиг машинки. Почему вычитаем? Допустим, машинка проехала на 10 пикселей вверх. Если гипотетическая камера хочет оказаться относительно машинки на той же позиции, что и раньше, то можно сказать, что камера неподвижна, а весь остальной мир спустился относительно нас на 10 пикселей вниз.

Заготовка во View готова, нужно связать ее с логической частью. Делать мы это будем через событие обновления игрового цикла. Добавим в наш класс GameplayEventArgs новое поле POVShift, в которое и будем записывать смещение относительно предыдущего цикла:

public class GameplayEventArgs : EventArgs
{
    public Dictionary<int, IObject> Objects { get; set; }
    public Vector2 POVShift { get; set; }
}

В модели же посчитаем эту величину смещения (создав в методе Update переменную playerInitPos, в которую сохраним положение игрока в начале цикла и затем вычтя ее из положения в конце) и передадим через событие Updated:

public void Update()
{
  Vector2 playerInitPos = Objects[PlayerId].Pos;
  foreach (var o in Objects.Values)
  {
    o.Update();
  }
  Vector2 playerShift = Objects[PlayerId].Pos - playerInitPos;
  Updated.Invoke(this, new GameplayEventArgs { 
    Objects = this.Objects, POVShift = playerShift
    } );
}

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

Осталось сделать так, чтобы View принимала новый параметр, для чего меняем метод LoadGameCycleParameters в интерфейсе IGamePlayView, его вызов в презентере и реализацию данного метода в GameCycleView.

public interface IGameplayView
{
    event EventHandler CycleFinished;
    event EventHandler<ControlsEventArgs> PlayerSpeedChanged;
   
  	void LoadGameCycleParameters(
      Dictionary<int, IObject> _objects, 
      Vector2 POVShift
    );
    void Run();
}
//Presenter
private void ModelViewUpdate(object sender, GameplayEventArgs e)
{
    _gameplayView.LoadGameCycleParameters(e.Objects, e.POVShift);
}
//View
public void LoadGameCycleParameters(Dictionary<int, IObject> Objects, Vector2 POVShift)
{
    _objects = Objects;
    _visualShift += POVShift;
}

Теперь, если мы запустим программу, то увидим что машинка при нажатии клавиш не исчезает. Но без других объектов непонятно, двигается она или нет. Добавим еще один объект, чтобы это стало очевидно — создадим еще одну машинку в нашем методе инициализации игрового цикла:

public void Initialize()
{
    Objects = new Dictionary<int, IObject>();
    _currentId = 1; //Инициализируем свободный ключ
    Car player = new Car();
    player.Pos = new Vector2(512 - 90, 500);
    player.ImageId = 1;
    player.Speed = new Vector2(0, 0);    
    Objects.Add(_currentId, player);
    PlayerId = _currentId;
    _currentId++;
  
  	Car anotherCar = new Car();
  	anotherCar.Pos = new Vector2(200, 50);
    anotherCar.ImageId = 1;
    anotherCar.Speed = new Vector2(0, 0);
  	Objects.Add(_currentId, anotherCar);
  	_currentId++;
}

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

Спасибо за внимание!

Комментарии (6)


  1. Razbezhkin
    20.07.2022 20:07

    Срасибо за статью. Можно ли в monogame сделать из изображения прямого отрезка дороги отрезок дуги дороги с указанным в качестве параметра радиусом?


    1. KayAltos Автор
      20.07.2022 21:24
      +1

      Спасибо за отзыв!
      Напрямую, насколько я знаю, нет. Можно просто самому написать алгоритм, который бы из кусков дороги составлял картинку произвольной формы, можно попробовать сделать нечто похожее через шейдер:
      https://habr.com/ru/post/128349/

      Другие решения мне, к сожалению, неизвестны.


  1. Gigatrop
    21.07.2022 13:19
    +1

    Пилил я 2D пиксельную игру на моногейме, в целом поначалу устраивало, для старта вполне годная штука. Но как только появились не примитивные требования, моногейм стал проблемой. Например потребовалось у спрайта две текстуры вместо одной, и такой возможности нет. Для не квадратных фигур пришлось писать свой код и вызывать какие-то внутренние методы, потому что моногейм рисует только прямоугольники. Не хватило также умных сортировок спрайтов, а не только мгновенной или по текстуре. И не понравилась постоянная возня со утилитами для ресурсов и какие-то промежуточные форматы для всего. А сейчас они уже месяц не могут выкатить минорную версию, где обновлена версия сишарпа, без новых фич, которая якобы готова. И OpenGL уже устарел, а Vulkan-а в моногейме нет. В общем плюнул я на это всё, и делаю на вулкане теперь. Сложно и времени на всё много уходит, но доступно всё API, а не урезанная абстракция.


    1. KayAltos Автор
      21.07.2022 18:06

      Ну, да, Monogame простой, это и плюс, и минус. И практически для всего надо свой код писать - все-таки это не полноценный движок. Это вопрос к тому, что человеку нужно. Для меня ближайшим аналогом является pygame.
      Что касается нескольких текстур - я просто делаю список текстур на объекте, а в Draw к нему обращаюсь. Или вы что-то другое имели в виду?
      PS. Для Vulkan надо еще больше самому писать, разве нет? Это же как "писать на OpenGL". Я делаю для себя, мне интересен процесс, но если хочется полноценно создавать игры, лучше уж готовый движок взять.


  1. Gigatrop
    21.07.2022 19:14
    +1

    Про текстуры я имел в виду чтобы в шейдере для одного спрайта было сразу две текстуры, то есть чтобы у метода Draw было два первых аргумента текстуры. Одна для цвета, другая для свечения. И там это нельзя сделать, потому что в вершинах передаётся только одна текстура, и прочие данные в вершинах в шейдер тоже передать нельзя, например второй цвет.

    На Vulkan писать сложнее, чем на OpenGL. И C# не совсем предназначен для таких целей, там приходится со всякими указателями работать и выделять память без сборщика мусора. Но в итоге в моём случае всё это стоит усилий, потому что удаётся сделать что хочешь. Готовый движок не подходит, потому что есть особенности физики, генерация мира как в террарии, но многослойная, и движки обычно с 2D плохо работают, или игрушечные весьма.

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


    1. KayAltos Автор
      21.07.2022 19:19

      Понял, спасибо!