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

4. Базовая структура проекта

4.1 Monogame, MVP и моя любовь к наворачиванию абстракций

Как было сказано в прошлый раз, после инициализации проекта на Monogame начинается бесконечный цикл, который последовательно вызывает два метода – Update и Draw. Поэтому выполняться будет только то, что находится внутри них. Помимо этого, внутри Update можно ловить события от пользователя – нажатие клавиш, мышки и так далее. В принципе, мелкую игру типа той, что я собираюсь реализовать, можно написать единой простыней внутри метода Update, и на этом закончить. Но мне хочется сделать программу такой, чтобы она была красивой внутри и снаружи. По крайней мере так, как я это вижу. Кроме того, в хорошо структурированной программе намного проще ориентироваться и поддерживать.

Поэтому при написании игры я попытаюсь реализовать в ней архитектурный паттерн MVP – Model-View-Presenter. Он является простым, как железный лом, при этом позволяет очень хорошо отделить логику приложения от его представления. А еще он весь завязан на событийной модели, а я очень люблю события в C#. Суть такова: программа делится на три фактически независимых модуля – View (Представление), Model (Модель) и Presenter (далее буду называть его Презентер потому что нормального русского названия я не нашел). View содержит ввод и вывод (но не их обработку), Model заведует данными и их обработкой, а Presenter – связующая прослойка – View и Model не знают друг о друге и никак между собой не связаны.

В C# это можно реализовать следующим образом: View содержит набор событий, которые активируются при вводе, Presenter реагирует на них и включает нужные методы обновления Model. Model обновляется, после чего сообщает через события, что обновление произошло, на что снова реагирует Presenter, и уже у View активирует методы изменения выводимой информации.

Схема работы MVP на примере сдвига персонажа вправо
Схема работы MVP на примере сдвига персонажа вправо

Обратите внимание на стрелки Презентера: View и Model не вызывают никаких его методов и не связаны с остальной программой. Они просто что-то делают, о чем сообщают остальной программе через активацию событий. Уже сам Presenter, будучи подписанным на эти события, реагирует нужным образом. Тем самым достигается то, что Представление и Модель совершенно не зависят друг от друга. В приведенном примере происходит следующее: при нажатии стрелки клавиатуры View активирует событие "Игрок двинулся", в которое передает аргумент, куда именно. Больше View ничего не делает. Presenter имеет набор методов, которые подписаны на события и View, и Model. Поэтому, при активации события "Игрок двинулся" Presenter вызывает соответствующий метод, который вызывает метод, который изменяет координату игрока уже внутри Model. Этот же метод Model включает событие, которое сообщает о том, что игрок успешно сдвинут. Presenter снова реагирует на это и вызывает метод, который говорит View, что позиция игрока изменилась и следует его перерисовать. На первый взгляд это может выглядеть громоздко, но потом становится очень удобно работать при расширении программы. Теперь посмотрим, как это конкретно реализовать в нашем случае.

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

4.2 Каркас MVP внутри проекта Monogame

Обычно Presenter имеет дело не с конкретными классами View и Model, а с соответствующими интерфейсами. А далее конкретные классы уже их реализуют. Поэтому, пока, вообще не думая о том, что у нас есть какой-то Monogame с классом Game1, создаем интерфейс для Model и назовем его IGameplayModel. Почему Gameplay, а не просто Model? Потому что я планирую делать еще главное и настроечное меню, которые будут вести себя по-другому. Анализируя предыдущий опыт, я пришел к выводу, что на этом этапе не нужно пытаться расписать сразу все. Именно это и сгубило вторую версию моей игры. Поэтому теперь я хочу попробовать добавлять нужные вещи постепенно, новая архитектура к этому располагает.

public interface IGameplayModel
{
    event EventHandler<GameplayEventArgs> Updated;

    void Update();
    void MovePlayer(Direction dir);

    public enum Direction : byte
    {
        forward,
        backward,
        right,
        left
    }
}

public class GameplayEventArgs : EventArgs
{
    public Vector2 PlayerPos { get; set; }
}

Сейчас мы определили, что тот, кто будет реализовывать интерфейс Модели должен уметь обновлять свою логику (метод Update) и сообщать программе о том, что он обновился посредством события Updated. Событие Updated точно будет передавать на Presenter какие-то события (как минимум, данные о том, что и где нужно отрисовывать), поэтому создаем класс GameplayEventArgs, экземпляр которого будем создавать при вызове события. Для тестового режима на карте у нас пока будет только игрок, поэтому единственный параметр, который будет передавать этот класс – позиция игрока в виде переменной типа Vector2. Этот типа является встроенным в Monogame и имеет две координаты X и Y с типом float.

Кроме того, зная, что мы будем задавать машинке направление движения, сделаем соответствующее перечисление Direction просто для того, чтобы для обозначения движения в коде вместо цифр 1-4 писать понятные слова. C# версии 9.0 позволяет создавать перечисления в интерфейсе, более ранние версии, насколько я помню - нет. В этом случае можно сунуть Direction просто внутри пространства имен проекта. Для полного счастья добавим метод MovePlayer, который будет двигать игрока в указанном направлении.

Переходим к View. Создаем интерфейс IGameplayView и подумаем, что он должен уметь. После каждого игрового цикла у нас должно идти обновление модели. Поэтому напишем соответствующее событие, при срабатывании которого View посигналит о том, что цикл завершен и пора обновлять модель. Далее, так как у нас игра про машинки, то логично будет сделать событие, которое срабатывает, когда пользователь захочет ее подвигать в каком-нибудь направлении. Это то, что View дает на выход. А что можно приказать сделать ему? Перерисовать экран с новыми параметрами. Здесь уже нужно вспомнить, что мы работаем с конкретной заданной заранее структурой (хотя, предлагаемый вариант можно реализовать и не в Monogame). Так как на роль View я назначу класс Game, у которого и так каждый цикл вызывается метод Draw, то здесь я сделаю метод не на отрисовку, а на передачу новых параметров под отрисовку (сейчас он принимает только позицию игрока). Пока достаточно – два события, один метод.

public interface IGameplayView
{
    //Включается в конце каждого цикла, чтобы обновить модель
    event EventHandler CycleFinished;
    event EventHandler<ControlsEventArgs> PlayerMoved;

    void LoadGameCycleParameters(Vector2 pos);
}

public class ControlsEventArgs : EventArgs
{
    public IGameplayModel.Direction Direction { get; set; }
}

Событие PlayerMoved передает при срабатывании аргументы через класс ControlsEventArgs, в котором сейчас только одно автосвойство – Direction типа нашего перечисления из модели – Direction. Далее создаем класс Презентера и через него соединяем события и методы IView и IModel.

public class GameplayPresenter
{
    private IGameplayView _gameplayView = null;
    private IGameplayModel _gameplayModel = null;

    public GameplayPresenter(
      IGameplayView gameplayView,
      IGameplayModel gameplayModel
    )
    {
        _gameplayView = gameplayView;
        _gameplayModel = gameplayModel;

        _gameplayView.CycleFinished += ViewModelUpdate;
        _gameplayView.PlayerMoved += ViewModelMovePlayer;
        _gameplayModel.Updated += ModelViewUpdate;

    }

    private void ViewModelMovePlayer(object sender, ControlsEventArgs e)
    {
        _gameplayModel.MovePlayer(e.Direction);
    }

    private void ModelViewUpdate(object sender, GameplayEventArgs e)
    {
        _gameplayView.LoadGameCycleParameters(e.PlayerPos);
    }

    private void ViewModelUpdate(object sender, EventArgs e)
    {
        _gameplayModel.Update();
    }
}

У презентера есть два поля с типами интерфейсов наших IView и IModel. Таким образом, на их место можно поставить любые классы, которые реализуют нужные интерфейсы, что мы и делаем в конструкторе. Определяем в качестве обработчиков событий View и Model соответствующие методы и создаем их. Теперь, когда в нашем View произойдет событие CycleFinished вызовется метод ViewModelUpdate, который, в свою очередь, вызовет метод Update у модели. Когда же у модели произойдет событие Updated, то Presenter вызовет событие ModelViewUpdate, который передает во View позицию игрока.

Теперь настало место для реализации наших интерфейсов, и начнем мы с View.

Издеваемся над исходной структурой проекта

Как я уже сказал, на роль View я назначил класс Game1, так как именно в нем есть средства отрисовки. Game1 уже унаследован от Game, однако C# разрешает множественную реализацию интерфейсов (позднее он будет реализовывать и интерфейс View менюшек). Начнем с того, меняем убогое название Game1 на GameCycleView, а после этого реализуем интерфейс IGameplayView. Далее, чтобы не путаться и для краткости буду про наши классы писать View/Представление и Model/Модель, а про исходные интерфейсы - IView и IModel.

public class GameCycleView : Game, IGameplayView
{
    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;

    public event EventHandler CycleFinished = delegate { };
    public event EventHandler<ControlsEventArgs> PlayerMoved = delegate { };


    public GameCycleView()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;

    }

    protected override void Initialize()
    {
        base.Initialize();
    }

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
    }

    public void LoadGameCycleParameters(Vector2 pos)
    {

    }

    protected override void Update(GameTime gameTime)
    {
        var keys = Keyboard.GetState().GetPressedKeys();
        if (keys.Length > 0)
        {
            var k = keys[0];
            switch (k)
            {
                case Keys.Escape:
                    {
                        Exit();
                        break;
                    }
            }
        }

        if (GamePad.GetState(PlayerIndex.One).
        Buttons.Back == ButtonState.Pressed ||
        Keyboard.GetState().IsKeyDown(Keys.Escape))
            Exit();

        base.Update(gameTime);
        CycleFinished.Invoke(this, new EventArgs());
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.CornflowerBlue);
        base.Draw(gameTime);
    }
}

В классе добавилось два события - CycleFinished и PlayerMoved. Их мы не трогаем, потому что на них должен подписывать свои обработчики Презентер, что мы уже сделали ранее. В Update мы пока только добавим активацию события того, что цикл завершился и пора обсчитывать новый.

Помимо этого я добавил обработчик нажатых клавиш. В массив keys записывается все, что игрок нажал, а далее, если этот массив не пустой, в k записывается первая нажатая клавиша и происходит ее обработка. Пока это немного криво, но в дальнейшем эта структура претерпит изменения. Тем не менее, такая запись мне кажется более удобной, чем предлагаемый изначально способ считывания через метод IsKeyDown (строка 48, пока я ее оставил для примера), так как считывание происходит один раз, а далее все обрабатывается через один switch вместо кучи if-ов, в которых легко запутаться.

На следующем шаге пишем модель. Создаем класс GameCycle и реализуем в нем интерфейс IGameplayModel:

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

    private Vector2 _pos = new Vector2(300, 300);

    public void Update()
    {
        _pos += new Vector2(1, 0);
        Updated.Invoke(this, new GameplayEventArgs { PlayerPos = _pos });
    }

    public void MovePlayer(IGameplayModel.Direction dir)
    {

    }
}

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

Структура почти готова. Теперь, чтобы система работала, нужно создать экземпляр Презентера и прикрутить к нему конкретные экземпляры View и Model. Создавать Presenter нужно так, чтобы все три части программы были независимы, поэтому нам нужно немного поменять код внутри Program, чтобы вызывать метод Run не напрямую:

public static class Program
{
    [STAThread]
    static void Main()
    {
        //using (var game = new GameCycleView())
        //var game = new GameCycleView();
        //game.Run();         
        GameplayPresenter g = new GameplayPresenter(
          new GameCycleView(), new GameCycle()
        );
    }
}

Однако, теперь нам надо придумать, как через Presenter запустить игру, так как она запускается вызовом конкретного метода Run именно у экземпляра класса Game. Для этого у интерфейса IGameplayView создадим метод, который имеет ту же сигнатуру, что и метод Run класса Game:

public interface IGameplayView
{
    //Включается в конце каждого цикла, чтобы обновить модель
    event EventHandler CycleFinished;
    event EventHandler<ControlsEventArgs> PlayerMoved;

    void LoadGameCycleParameters(Vector2 pos);
    void Run();
}

А у Презентера создадим метод LaunchGame, из которого и будем вызывать Run:

private void LaunchGame()
{
    _gameplayView.Run();
}

Таким образом, получается, что созданный не нами Game реализует нужный метод интерфейса и теперь, когда мы вызовем из Презентера в Program метод Run нашего View вызовется именно метод Run класса Game:

public static class Program
{
    [STAThread]
    static void Main()
    {
        //using (var game = new GameCycleView())
        //var game = new GameCycleView();
        //game.Run();         
        GameplayPresenter g = new GameplayPresenter(
          new GameCycleView(), new GameCycle()
        );
        g.LaunchGame();
    }
}

Теперь, если мы запустим игру, то увидим все то же голубое окно, потому что наш View нигде не получает команду на отрисовку и использование данных, которые мы скидываем из модели. Однако, если посмотреть, что творится под капотом, то мы увидим, что программа исправно крутит циклы и меняет позицию игрока:

Давайте, научим наш View показывать игроку, что творится во внутреннем мире нашей игры. Для этого просто будем рисовать заглушку в виде белого квадрата на том месте, где у нас сейчас игрок. Для начала View должен запомнить, где же наш игрок. То есть, позицию, которая передается через LoadGameCycleParameters нужно где-то сохранить, так как передать ее напрямую в Draw мы не можем и перегрузку сделать на него тоже не можем (по крайней мере, насколько я знаю). Поэтому создаем прямо в нашем классе View поле с позицией игрока, а в LoadGameCycleParameters напишем, чтобы она передавала в это поле новую позицию игрока:

public class GameCycleView : Game, IGameplayView
{
    // <.................................>

    private Vector2 _playerPos = Vector2.Zero;

    // <.................................>

    public void LoadGameCycleParameters(Vector2 pos)
    {
        _playerPos = playerPos;
    }
    // <.................................>

}

Знаком <.................................> здесь и далее я буду показывать пропуски кода, который не менялся, чтобы не захламлять статью.

Осталось только прописать в Draw, чтобы в координате _playerPos рисовался квадрат с заданной стороной. Ну, то есть, прописать что-то типа Draw.Rectangle(_playerPos, 20, 20), да? Нет. Я не знаю, почему, но Monogame не умеет рисовать двухмерные примитивы. Если вы начнете гуглить monogame draw primitives или draw rectangle, то увидите кучу удивительно долгих роликов, суть которых сводится к тому, что нужно подготовить спрайт вашего примитива и дальше его масштабировать. Например:

Примечание: Кстати, этот канал содержит один из самых подробных и понятных туториалов по тому, как программировать на Monogame базовые вещи.

Печально, но факт. Если я неправ, и можно сделать это проще, то, пожалуйста, напишите.

Поэтому сначала необходимо научиться тому, как загружать в Monogame ресурсы. Для этого используется специальный дополнительный инструмент под названием MonoGame Content Builder (MGCB). Если вы воспользовались моим советом из предыдущей статьи и создали проект через template, то он у вас должен подключиться автоматически и оказаться в папке Content проекта:

Если два раза щелкнуть по нему, то появится окошко с ресурсами только файла White_Placeholder у вас пока быть не должно):

Если нет, то щелкайте по значку в обозревателе решений правой кнопкой, «Открыть с помощью…» и выбирайте, чтобы MGCB открывался через MonoGame Pipeline Tool:

Давайте, теперь подключим к нашей игре наш первый ресурс – заглушку в виде белого квадрата. Создаем в пеинте белый квадратик нужного размера, сохраняем как png и кидаем в папку Content нашего проекта:

Теперь открываем MGCB, нажимаем Add Existing Item, добавляем наш файл и нажимаем Build:

Теперь Monogame будет видеть наш рисунок и его можно будет загрузить как спрайт в код. Спрайты хранятся в специальном типе Texture2D. Создадим соответствующее поле в нашем классе View, а в методе LoadContent загрузим в него нашу заглушку:

public class GameCycleView : Game, IGameplayView
{
    // <.................................>
    private Vector2 _playerPos = Vector2.Zero;
    private Texture2D _playerImage;

    public GameCycleView()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
    }

    protected override void Initialize()
    {
        base.Initialize();
    }

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
        _playerImage = Content.Load<Texture2D>("White_Placeholder");
    }

    // <.................................>
}

Свойств RootDirectory показывает, какая папка для ресурсов является корневой. Так как наша заглушка лежит прямо в корневой папке ресурсов, то в Content.Load пишем просто название самого файла без его расширения. Теперь мы можем написать команду на отрисовку игрока – идем в метод Draw:

protected override void Draw(GameTime gameTime)
{
  GraphicsDevice.Clear(Color.CornflowerBlue);           
  base.Draw(gameTime);
  _spriteBatch.Begin();
  _spriteBatch.Draw(_playerImage, _playerPos, Color.White);
  _spriteBatch.End();            
}        

Для отрисовки используется экземпляр класса SpriteBatch. Для начала отрисовки нужно вызвать у него метод Begin, в конце метод End, а между ними вызывать метод Draw на каждый спрайт. Метод Draw содержит несколько перегрузок. Самая простая содержит 3 аргумента – какой спрайт мы рисуем, координату левого верхнего угла, от которого будет отрисовывать прямоугольник спрайта, и цветовой фильтр. При белом фильтре он будет рисоваться как есть. Теперь мы можем запустить приложение. Ура! Мы видим белый квадрат, который медленно ползет вправо.

Здесь, мне кажется, уместно прерваться и задать вопрос – зачем так сложно? Можно было менять позицию игрока прямо из Game1 и получить тот же самый результат безо всяких лишних абстракций и возни с подключением View, Model и Presenter друг к другу, добавив к исходному проекту 5 строчек. Дело в том, что чем дальше проект будет разрастаться, тем сложнее будет с ним работать и что-то добавлять. Когда я делал эту игру первый раз, то выяснилось, что это происходит намного раньше, чем можно было бы подумать.

4.2 Управление

Настало время сделать так, чтобы наш квадратик управлялся. Для этого уже все подготовлено. Уберем из Update модели смещение вправо и допишем метод MovePlayer:

public class GameCycle : IGameplayModel
{
	public event EventHandler<GameplayEventArgs> Updated = delegate { };
        
  private Vector2 _pos = new Vector2(300,300);
        
  public void Update()
  {
  	_pos += new Vector2(1,0);
    Updated.Invoke(this, new GameplayEventArgs { PlayerPos = _pos });                  
  }
      
  public void MovePlayer(IGameplayModel.Direction dir)
  {
  	switch (dir)
    {
    	case IGameplayModel.Direction.forward:
    	{
    		_pos += new Vector2(0, -1);
    		break;
   		}
    	case IGameplayModel.Direction.backward:
    	{
    		_pos += new Vector2(0, 1);
    		break;
    	}
    	case IGameplayModel.Direction.right:
    	{
    		_pos += new Vector2(1, 0);
    		break;
    	}
    	case IGameplayModel.Direction.left:
    	{
   		  _pos += new Vector2(-1, 0);
    		break;
    	}
   	}          
  }       
}

Настало время активировать во View ранее незадействованное событие – PlayerMoved. Ловить воздействие от пользователя будем в методе Update нашей View.

protected override void Update(GameTime gameTime)
{
    var keys = Keyboard.GetState().GetPressedKeys();
    if (keys.Length > 0)
    {
        var k = keys[0];
        switch (k)
        {
            case Keys.W:
                {
                    PlayerMoved.Invoke(
                      this, 
                      new ControlsEventArgs { 
                        Direction = IGameplayModel.Direction.forward }
                    );
                    break;
                }
            case Keys.S:
                {
                  PlayerMoved.Invoke(
                      this, 
                      new ControlsEventArgs { 
                        Direction = IGameplayModel.Direction.backward }
                    );
                    break;                    
                }
            case Keys.D:
                {
                  PlayerMoved.Invoke(
                      this, 
                      new ControlsEventArgs { 
                        Direction = IGameplayModel.Direction.right }
                    );                    
                    break;
                }
            case Keys.A:
                {
                  PlayerMoved.Invoke(
                      this, 
                      new ControlsEventArgs { 
                        Direction = IGameplayModel.Direction.left }
                    );
                    break;                    
                }
            case Keys.Escape:
                {
                  Exit();
                  break;
                }
        }
    }  
    base.Update(gameTime);    
    CycleFinished.Invoke(this, new EventArgs());
}

Теперь при запуске сможем управлять нажим квадратиком:

Какая красота! Оно не только показывает себя, но и может реагировать на наше воздействие.

4.3 Система объектов

Статья получилась достаточно длинной, но, тем не менее, я хочу добавить кое-что еще. Так как игра у нас про машинки, то логичнее было бы добавить машинкам параметр скорости и менять ее направление. А движение уже происходило бы автоматически при каждом цикле. Тогда машинка не будет тормозить каждый раз, когда мы отпустим кнопку. Переименуем метод MovePlayer и событие PlayerMoved в ChangePlayerSpeed и PlayerSpeedChanged соответственно.

Однако, мы больше не будем напрямую добавлять переменные в нашу модель. Понятно, что у нас будет много машин. Их поведение будет одинаковым, включая машину игрока, с той лишь разницей, что ее параметры будут меняться в зависимости от действия человека за компьютером. Логичным решением было бы сделать класс машины с полем Speed и создать в модели список машин, позицию которых менять по циклу. Но у нас в игре будут не только машины, а много объектов. Поэтому создадим интерфейс IObject, у которого будут его координаты, ключ его спрайта (о котором скажу чуть позже) и метод Update, чтобы наша модель могла по циклу обновлять состояние своих объектов, а как это обновление происходит, они решали бы сами. Возможно, позднее сюда тоже нужно будет прикрутить набор событий для различных ситуаций, но пока не будем захламлять код.

public interface IObject
{
  	int ImageId { get; set; }   

    Vector2 Pos { get; }
    
    void Update();  
}

Далее создадим класс Car, который реализует интерфейс IObject:

public class Car : IObject
{
    public int ImageId { get; set; }

    public Vector2 Pos { get; set; }
    public Vector2 Speed { get; set; } 

    public void Update()
    {
    	Pos += Speed        
    }   
}

У машины есть параметр скорости, и при обновлении состояния машина двигается с нужной скоростью в заданном направлении. Интерфейс модели IModel поменяется – мы добавим в него словарь объектов, ключ игрока и метод Initialize, через который будет задаваться начальное состояние модели:

public interface IGameplayModel
{
    int PlayerId { get; set; }
    Dictionary<int, IObject> Objects { get; set; }
    event EventHandler<GameplayEventArgs> Updated;

    void Update();
    void ChangePlayerSpeed(Direction dir);
    void Initialize();    

    public enum Direction : byte
    {
        forward,
        backward,
        right,
        left
    }
}
public class GameplayEventArgs : EventArgs
{
    public Dictionary<int, IObject> Objects { get; set; }    
}

Класс GamePlayEventArgs тоже поменялся и передает не одну позицию, а словарь объектов их модели. Класс Модели теперь выглядит следующим образом:

public class GameCycle : IGameplayModel
{
	public event EventHandler<GameplayEventArgs> Updated = delegate { };
  
  public int PlayerId { get; set; }
        
  public Dictionary<int, IObject> Objects { get; set; }
  
  public void Initialize()
  {
  	Objects = new Dictionary<int, IObjects>();
    Car player = new Car();
    player.Pos = new Vector2 (250, 250);
    player.ImageId = 1;
    player.Speed = new Vector2 (0, 0);
    Objects.Add(1, player);
    PlayerId = 1;
  }
  
  public void Update()
  {
  	foreach (var o in Objects.Values)
    {
    	o.Update();
    }
    Updated.Invoke(this, new GameplayEventArgs { Objects = this.Objects });                  
  }
      
  public void ChangePlayerSpeed(IGameplayModel.Direction dir)
  {
  	Car p = (Car)Objects[PlayerId];
  	switch (dir)
    {
    	case IGameplayModel.Direction.forward:
    	{
    		p.Speed += new Vector2(0, -1);
    		break;
   		}
    	case IGameplayModel.Direction.backward:
    	{
    		p.Speed += new Vector2(0, 1);
    		break;
    	}
    	case IGameplayModel.Direction.right:
    	{
    		p.Speed += new Vector2(1, 0);
    		break;
    	}
    	case IGameplayModel.Direction.left:
    	{
   		  p.Speed += new Vector2(-1, 0);
    		break;
    	}
   	}          
  }       
}

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

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

public class GameCycleView : Game, IGameplayView
{
    // <.................................>
    private Dictionary<int, IObject> _objects = new Dictionary<int, IObject>();
    private Dictionary<int, Texture2D> _textures = new Dictionary<int, Texture2D>();

    // <.................................>

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
        _textures.Add(1, Content.Load<Texture2D>("White_Placeholder"));
    }
    public void LoadGameCycleParameters(Dictionary<int, IObject> Objects)
    {
        _objects = Objects;        
    }
    // <.................................>
    protected override void Draw(GameTime gameTime)
		{
  		GraphicsDevice.Clear(Color.CornflowerBlue);           
  		base.Draw(gameTime);
  		_spriteBatch.Begin();
      foreach (var o in _objects.Values)
      {
      	_spriteBatch.Draw(_textures[o.ImageId], _playerPos, Color.White);
      }  		
  		_spriteBatch.End();            
		}    
}

Какой именно спрайт рисовать мы определяем по ключу ImageId объекта. Осталось только в Презентере поменять метод ModelViewUpdate под новый набор параметров, и можно запускать:

private void ModelViewUpdate(object sender, GameplayEventArgs e)
{
	_gameplayView.LoadGameCycleParameters(e.Objects);
}

Управление во View при этом остается неизменным – входные аргументы там те же, поменялось только исполнение в модели. Запускаем и смотрим:

Что ж, наш милый белый квадратик теперь перемещается постоянно, мы лишь меняем направление. Как я уже сказал, статья получилась длинной, потому что необходимо было сделать полноценную заготовку под дальнейшую игру, чтобы в следующий раз не пришлось вникать, что мы делаем. Дальше материал будет легче для восприятия. Спасибо за внимание и до следующей части!

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


  1. rue-ryuzaki
    14.07.2022 17:57
    -1

    Эх, обычно все первым делом рисуют треугольник, а тут - квадрат!

    В Update мы пока только добавим активацию события того, что цикл завершился и пора обсчитывать новый (строка 52).

    Тем не менее, такая запись мне кажется более удобной, чем предлагаемый изначально способ считывания через метод IsKeyDown (строка 48, пока я ее оставил для примера)

    Не знаю, недоработка ли это хабра (нет возможности делать блок кода с номерами строк), но не понятно как это соотносится с примером в кода выше. Лучше уж написать комментарии в самом коде.

    Код из поста

    Опять же, претензии скорее к хабру, но на телефоне и на компе (хромиум и огнелис) наблюдается кривое выравнивание из-за наличия и табов и пробелов.

    Как обычно, в таких статьях не хватает репозиториев с кодом.

    За старания - респект!


    1. KayAltos Автор
      14.07.2022 18:22

      Спасибо!


    1. mayorovp
      14.07.2022 19:35
      +2

      Пожалуйста, посмотрите на свой комментарий с компьютера...


      1. rue-ryuzaki
        14.07.2022 20:49

        Да, уже видел. К сожалению, уже не могу его отредактировать.


  1. rue-ryuzaki
    15.07.2022 02:52
    +2

    Не силен в monogame, разве в update методах логики не следует также использовать прошедшее время с прошлого update? Иначе обновление мира будет пропорционально fps.

    Также следует обрабатывать все нажатые клавиши в update.

    Напоследок, последний вариант реализации класса GameCycle не соответствует интерфейсу IGameplayModel.

    Интересно посмотреть, как будет реализовано создание UI и обработка взаимодействия с ним!


    1. KayAltos Автор
      15.07.2022 09:57

      Спасибо, поправил