Найти тему

Как написать игру на Monogame, не привлекая внимания санитаров. Часть 1, обмазываемся абстракциями

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

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

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

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

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

Рисунок 5 — Схема работы паттерна MVP
Рисунок 5 — Схема работы паттерна MVP

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

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

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

Рисунок 6 — Интерфейс модели геймплея
Рисунок 6 — Интерфейс модели геймплея

Анализируя предыдущий опыт, я пришел к выводу, что на этом этапе не нужно пытаться расписать сразу все. Именно это и сгубило вторую версию моей игры. Поэтому теперь я хочу попробовать добавлять нужные вещи постепенно, новая архитектура к этому располагает. Таким образом, сейчас мы определили, что тот, кто будет реализовать интерфейс модели должен уметь обновлять свою логику (метод Update) и сообщать программе о том, что он обновился посредством события Updated. Событие Updated точно будет передавать на Presenter какие-то события (как минимум, данные о том, что и где нужно отрисовывать), поэтому создаем класс GameplayEventArgs, экземпляр которого будем создавать при вызове события. Для тестового режима на карте у нас пока будет только игрок, поэтому единственный параметр, который будет передавать этот класс – позиция игрока в виде типа Vector2 из Monogame.

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

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

Рисунок 7 — Интерфейс для View
Рисунок 7 — Интерфейс для View

Событие PlayerMoved передает при срабатывании аргументы через класс ControlsEventArgs (который надо наследовать от EventArgs, как и GameplayEventArgs с рисунка 7, но на скринах я забыл это сделать, а откатываться к тому варианту мне лень), в котором сейчас одно автосвойство – direction (которое тоже следует именовать с большой буквы – дальше исправлено) типа нашего перечисления из модели – Direction.

Далее создаем класс презентера и через него соединяем события и методы IView и IModel (Рис. 8).

Рисунок 8 — Структура презентера
Рисунок 8 — Структура презентера

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

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

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

Как я уже сказал, на роль View я назначил класс Game1, так как именно в нем есть средства отрисовки. Game1 уже унаследован от Game, однако C# разрешает множественную реализацию интерфейсов (позднее он будет реализовывать и интерфейс View менюшек). Кроме того, меняем убогое название Game1 (Рис. 9). Далее наш класс View я буду называть просто View, а наш класс Model — просто Model или моделью.

Рисунок 9 — Реализация интерфейса View
Рисунок 9 — Реализация интерфейса View

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

Рисунок 10 — Метод Update
Рисунок 10 — Метод Update

На строки 38-49 не обращайте внимания, это осталось с предыдущих экспериментов. Переходим в модели.

Создаем класс GameCycle и реализуем в нем интерфейс IGameplayModel (Рис. 11).

Рисунок 11 — Класс GameCycle
Рисунок 11 — Класс GameCycle

Для начала создадим поле _pos, которое будет обозначать позицию игрока. В Update пока напишем просто, что игрок сдвигается на 1 единицу вправо каждый шаг цикла. Теперь, чтобы система работала, нужно создать экземпляр презентера и прикрутить к нему конкретные экземпляры View и Model. Создавать презентер надо так, чтобы все три части программы были независимы, поэтому нам нужно немного поменять код внутри Program, чтобы вызывать метод Run не напрямую (Рис. 12).

Рисунок 12 — Создание презентера в методе Main
Рисунок 12 — Создание презентера в методе Main

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

Таким образом, получается, что созданный не нами Game реализует нужный метод интерфейса и теперь, когда мы вызовем из презентера метод Run нашего View вызовется именно метод Run класса Game (Рис. 14).

Рисунок 14 — Запуск игры через презентер
Рисунок 14 — Запуск игры через презентер

Честно говоря, я не заметил разницы в работе с using или без него, поэтому оставил так. Возможно, мне это еще аукнется.

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

Рисунок 15 – Оно живое, просто пока не может нам об этом сказать
Рисунок 15 – Оно живое, просто пока не может нам об этом сказать

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

Рисунок 16 — Запись во View позиции игрока
Рисунок 16 — Запись во View позиции игрока

Теперь осталось только прописать в Draw, чтобы в координате _playerPos рисовался квадрат с заданной стороной. Ну, то есть, прописать что-то типа Draw.Rectangle(_playerPos, 20, 20), да? Нет. Я не знаю, почему, но Monogame не умеет рисовать двухмерные примитивы. Трехмерные может, кстати. А именно двухмерные – нет. Если вы начнете гуглить monogame draw primitives или draw rectangle, то увидите кучу удивительно долгих роликов, суть которых сводится к тому, что нужно подготовить спрайт вашего примитива и дальше его масштабировать. То есть, Monogame не умеет делать то, что умеет делать из коробки даже Windows Forms. Еще смешнее ситуацию делает то, что у Monogame есть класс Rectangle, но он используется именно для расчетов, например, для создания коллайдеров. Печально, но факт. Если я неправ, и можно сделать это проще, то, пожалуйста, напишите.

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

Если два раза щелкнуть по нему, то появится окошко как на рисунке 16б (только файла White_Placeholder у вас пока быть не должно). Если нет, то щелкайте по значку в обозревателе решений правой кнопкой, «Открыть с помощью…» и выбирайте, чтобы MGCB открывался через MonoGame Pipeline Tool (Рис. 18).

Рисунок 18 — Открываем MGCB правильно
Рисунок 18 — Открываем MGCB правильно

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

Рисунок 19 – Создание заглушки
Рисунок 19 – Создание заглушки

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

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

Рисунок 21 — Загружаем спрайт в игру
Рисунок 21 — Загружаем спрайт в игру

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

Рисунок 22 — Отрисовка белого квадрата
Рисунок 22 — Отрисовка белого квадрата

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

-20

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

4.2 Управление

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

Рисунок 23 — Подключаем возможность управления игроком в модели
Рисунок 23 — Подключаем возможность управления игроком в модели

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

Рисунок 24 — Управляющие сигналы машинке игрока
Рисунок 24 — Управляющие сигналы машинке игрока

Я не использую предлагаемый изначально Monogame IsKeyDown, так как он фиксирует одно нажатие, в то время как GetPressedKeys возвращает массив нажатых в настоящий момент клавиш типа Keys. Таким образом, событие PlayerMoved будет включаться, пока я держу клавишу нажатой.

-23

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

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

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

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

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

Класс GamePlayEventArgs теперь тоже поменялся и передает не одну позицию, а словарь объектов их модели (Рис. 26а). В методе инициализации мы пока напрямую задаем игрока, его параметры и позиции, но позднее это будет переделано (Рис. 26б).

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

Рисунок 27 — Изменение скорости машинки игрока
Рисунок 27 — Изменение скорости машинки игрока

Во View мы будем теперь хранить не позицию игрока и его спрайт, а соответствующие словари. Соответственно, наш белый квадратик мы кладем теперь именно в словарь спрайтов (Рис. 28).

Рисунок 28 — Новая система объектов во View
Рисунок 28 — Новая система объектов во View

Draw тоже изменим под новую систему (Рис. 29).

Рисунок 29 — Отрисовка набора объектов
Рисунок 29 — Отрисовка набора объектов

Какой именно спрайт рисовать мы определяем по ключу ImageId объекта.

Осталось только в презентере поменять метод ModelViewUpdate под новый набор параметров и можно запускать (Рис. 30).

Рисунок 30 — Обновление презентера
Рисунок 30 — Обновление презентера

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

-30

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

-31