Целью данной статьи является систематизация материалов для создания простых игр на фреймворке Monogame. В статье будут рассмотрены решения типичных задач, с которыми приходится сталкиваться всем разработчикам при создании игр для мобильных платформ: независимость текстур от разрешения экрана, создание меню и смена игровых сцен (экранов), нюансы работы со звуком и шрифтами, сохранение рекордов. В статье приведены примеры кода, а так же даны ссылки на полезные и актуальные источники информации по Monogame и разработке под платформу Windows в целом, ну и конечно, моя история создания своих первых игр.

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

Являясь пользователем операционной системы Windows как на персональном компьютере, так и на телефоне, решил, что наиболее логичным будет остановить свой выбор на языке C#, так как хотелось пользоваться «родными» инструментами разработки, и начать его изучение на примере создания простой игры для телефона. Взявшись за дело, я ставил себе цели изучить базовые возможности языка, среды разработки и всей экосистемы от Microsoft в целом, с прицелом на свое дальнейшее развитие в области программирования.

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

В результате наших трудов получилась простая и незатейливая игра для тренировки визуальной памяти, написанная без сторонних библиотек только на C# и XAML. Игроку дается время заполнить расположение кругов на экране, затем ему необходимо восстановить их по памяти. Всего в игре 10 уровней, на каждом из которых количество кругов увеличивается. Попыток дается на одну-две больше, чем количество объектов на экране. Для прохождения игры у игрока есть 5 жизней.

image

Разработка и публикация первой игры позволили мне разобраться и получить небольшой опыт по следующим важным пунктам:
1) Базовые знания по языку C#, XAML, и платформе WinRT в целом.
2) Локализация приложения [ссылка].
3) Добавление механизма оценивания [ссылка].
4) Создание аккаунта и публикация в магазине Windows Store.
5) Настройка и добавление рекламы AdDuplex, Smaato, Microsoft Advertising в приложение через рекламного посредника Microsoft [ссылка, ссылка, ссылка].

К моменту публикации первой игры уже окончательно сформировалась новая идея и понимание того, чего я хочу от своего следующего проекта. Я определил для себя основные требования к будущей игре:
1) Управление должно осуществляться одним нажатием (одним пальцем).
2) Без уровней и содержать следующие сцены: стартовый экран, меню, инструкции, игра, пауза, рестарт.
3) Должна быть бесконечной, нацеленной на набор максимального количества очков.
4) Должна быть увлекательной и сложной.
5) Конечно же красивой.

Концепция игры заключалась в следующем: в центре экрана находится объект (назовем его «ядро»), к которому непрерывно и в произвольном направлении движутся другие объекты, и задача игрока – защитить ядро. Каждое успешное отражение летящего к ядру объекта, дает очки игроку, а неудача приводит к поражению, и игра начинается заново.

Дизайнер терпеливо и ответственно подходила к воплощению моих «хотелок», создавая концепт игры в стилистике sci-fi.

image

Остановившись на основных решениях в дизайне фигур, затем мучительно выбирали между цветовыми гаммами.

image

Уходить от нативных средств разработки не хотелось – необходимо было закрепить полученные знания. Поэтому далее последовал анализ существующих бесплатных движков на С#. Одним из определяющих факторов при выборе была доступность большого количества обучающих материалов и примеров. В итоге остановил свой выбор на Monogame, представляющий собой кроссплатформенную OpenSource-реализацию некогда популярного фреймворка Microsoft XNA 4.

MonoGame предоставляет возможность разрабатывать не только для разных платформ, но и на разных платформах. На официальном сайте на странице загрузок можно найти пакеты для различных ОС: Windows, MacOS, Linux. На ОС Linux, а также на MacOS понадобится среда Xamarin Studio. Для разработки под Windows 8.1 потребуется Windows 8.1 SDK, которая включена в полнофункциональную бесплатную Visual Studio Community 2013, а для Windows 10 — соответственно Windows 10 SDK и Visual Studio Community 2015.

Новичкам следует учесть, что готовый шаблон игры MonoGame под Windows Phone 8.1 есть только в Visual Studio Community 2013, в версии 2015 года вы уже увидите шаблон для UAP приложения.

Коротко о движке


В MonoGame реализована модель игрового цикла:

image
[ссылка на источник]

1) В методе Initialize() происходит инициализация используемых переменных и объектов, начального состояния игры.
2) Затем в методе LoadContent() в приложение загружаются различные ресурсы, которые применяются в игре — аудиофайлы, файлы изображений и так далее.
3) Методы Update() и Draw () представляют игровой цикл. С определенной периодичностью (по умолчанию 60 раз в секунду) в методе Update() выполняется опрос игровых объектов, производятся вычисления, например позиции персонажей. Метод Draw() отвечает только за перерисовку сцен и игровых объектов. Оба метода принимают в качестве параметра объект GameTime — он хранит время, прошедшее с начала игры.
4) При выходе из игрового цикла управление передается в метод UnloadContent(). В этом методе происходит выгрузка ранее использованных ресурсов.
5) После этого игра завершается, и приложение закрывается.

Данный алгоритм представлен в классе Game1{} при создании проекта MonoGame:

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
 
namespace Game1
{
    public class Game1 : Game
    {
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;
 
        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
        }
 
        // Выполняет начальную инициализацию игры
        protected override void Initialize()
        {
            base.Initialize();
        }
 
        // Загружает ресурсы игры
        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice); 
        }
 
        // Вызывается при завершении игры для выгрузки использованных ресурсов
        protected override void UnloadContent()
        {
             
        }
 
        // Обновляет состояние игры, управляет ее логикой
        protected override void Update(GameTime gameTime)
        { 
            base.Update(gameTime);
        }
 
        // Выполняет отрисовку на экране
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);
 
            base.Draw(gameTime);
        }
    }
}

Глобальная переменнная GraphicsDeviceManager graphics позволяет получить доступ к графическому устройству компьютера, смартфона, планшета, игровой консоли. Другой глобальный объект — SpriteBatch spriteBatch служит для отрисовки спрайтов — изображений, которые используются в игре.

Объект Content предоставляет доступ к содержимому игры. Следует отметить, что добавление в проект различных текстур, аудиофайлов, шрифтов и т.п. происходит через MonoGame Pipeline — утилиту, которая преобразует исходные файлы в файлы .xnb, с которыми уже идет непосредственная работа приложения.

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

Независимость графики от разрешения экрана


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

private readonly Rectangle screenBounds;
private readonly Matrix screenXform;

В конструкторе класса создадим переменную для хранения значения масштаба графики, которая может определяться как соотношение значения разрешения экрана девайса к разрешению текстур по высоте. В примере указано значение 1080, так как использовались текстуры с разрешением 1920х1080 пикселей. Далее задаем значение масштаба для матрицы преобразования по осям XYZ.

var screenScale = graphics.PreferredBackBufferHeight / 1080.0f; 
screenXform = Matrix.CreateScale(screenScale, screenScale, 1.0f);

На этапе отрисовки игрового цикла (В методе Draw()) используем значение масштаба в качестве параметра для перегруженного метода SpriteBatch.Begin() [ссылка]:

spriteBatch.Begin(SpriteSortMode.Deferred, null, null, null, null, null, screenXform);


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

screenBounds = new Rectangle(0, 0,
    (int)Math.Round(graphics.PreferredBackBufferWidth / screenScale),
    (int)Math.Round(graphics.PreferredBackBufferHeight / screenScale));

center = screenBounds.Center.ToVector2();

Теперь, для корректной работы с тачскрином, необходимо задать границы обработки нажатий в методе Initialize():

TouchPanel.DisplayWidth = screenBounds.Width;
TouchPanel.DisplayHeight = screenBounds.Height;

Нюансы работы со звуком


Чтобы пройти сертификацию приложения в магазине Windows Store необходимо соблюдать рекомендации Microsoft [ссылка, ссылка].

Что касается работы со звуком, то запускаемое приложение или игра не должны прерывать музыку пользователя, если она воспроизводится. Для этого осуществим проверку при запуске игры в методе Initialize():

if (MediaPlayer.GameHasControl)
   {
       MediaPlayer.Stop();
   }

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

Для воспроизведения фоновой музыки или звуковых эффектов в игре с учетом режима «Со звуком/Без звука» команда на воспроизведение в методе Update() может выглядеть следующим образом:

if (!isMuted && MediaPlayer.State != MediaState.Playing && MediaPlayer.GameHasControl)
    {
        MediaPlayer.Play(backGroundMusic);
    }

Работа со шрифтами


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

image

Выбираем SpriteFont Description и задаем имя. Жмем ОК.

image

Для того чтобы шрифты не потеряли качество отображения при масштабировании изображения, необходимо изменить формат текстур с Compressed на Color, как показано на рисунке ниже.

image

Нажимаем Build и переходим в папку с проектом, открываем подпапку Content. Здесь должен находиться сгенерированный файл *.spritefont, который необходимо открыть в удобном для вас текстовом редакторе (для удобства я открываю в той же Visual Studio). В указанных ниже тегах можно задать любой размер тип шрифта из находящихся в системной папке.

<!--
Modify this string to change the font that will be imported.
-->
<FontName>Arial</FontName>

<!--
Size is a float value, measured in points. Modify this value to change
the size of the font.
-->
<Size>12</Size>

Сохранение рекордов


Для сохранения результатов игры удобно использовать формат XML. Для доступа к данным ресурсам приложения используется сериализация. Чтобы исключить задержки в игре при чтении/записи данных необходимо использовать асинхронный подход.

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

public async void LoadHighScore()
    {
         await readXMLAsync();
    }

public async void SaveHighScore()
    {
         await writeXMLAsync();
    }

private async Task writeXMLAsync()
    {
         var serializer = new DataContractSerializer(typeof(Int16));
         using (var stream = await ApplicationData.Current.LocalFolder.OpenStreamForWriteAsync(
              "highscore.xml", CreationCollisionOption.ReplaceExisting))
             {
                  serializer.WriteObject(stream, highscore);
             }
    }

private async Task readXMLAsync()
    {
         var serializer = new DataContractSerializer(typeof(Int16));

         // Проверка наличия файла
         bool existed = await FileExists(ApplicationData.Current.LocalFolder, "highscore.xml");
         if (existed)
         {
              using (var stream = await ApplicationData.Current.LocalFolder.OpenStreamForReadAsync("highscore.xml"))
                  {
                      highscore = (Int16)serializer.ReadObject(stream);
                  }
         }
    }
        
      // Проверка наличия файла
public async Task<bool> FileExists(StorageFolder folder, string fileName)
    {
         return (await folder.GetFilesAsync()).Any(x => x.Name == fileName);
    }

Переход по игровым сценам


Способов реализации довольно много, однако для простой игры удобно использовать перечисления. Для этого в игровом классе объявим перечисление с игровыми состояниями и создадим его экземпляр.

enum GameState
{
    Menu,
    Gameplay,
    EndOfGame,
}

GameState state;

Далее модифицируем методы Update() и Draw() таким образом, чтобы на этапе вычислений и отрисовки графики обрабатывались данные из актуального состояния игры.

Метод Update():
void Update(GameTime deltaTime)
{
    base.Update(deltaTime);
    switch (state)
    {
        case GameState.Menu:
            UpdateMenu(deltaTime);
            break;
        case GameState.Gameplay:
            UpdateGameplay(deltaTime);
            break;
        case GameState.EndOfGame:
            UpdateEndOfGame(deltaTime);
            break;
    }
}

Метод Draw():

void Draw(GameTime deltaTime)
{
    base.Draw(deltaTime);
    switch (state)
    {
        case GameState.Menu:
            DrawMenu(deltaTime);
            break;
        case GameState.Gameplay:
            DrawGameplay(deltaTime);
            break;
        case GameState.EndOfGame:
            DrawEndOfGame(deltaTime);
            break;
    }
}

Определим новые методы, реализующие логику перехода между состояниями игры, вычисления и отрисовку игровых сцен.

void UpdateMenu(GameTime deltaTime)
{
    // Обрабатывает действия игрока в экране меню
    if (pushedStartGameButton)
        state = GameState.GamePlay;
}

void UpdateGameplay(GameTime deltaTime)
{
    // Обновляет состояние игровых объектов, действия игрока.
    if (playerDied)
        state = GameState.EndOfGame;
}

void UpdateEndOfGame(GameTime deltaTime)
{
    // Обрабатывает действия игрока, сохраняет результаты
    if (pushedMenuButton)
        state = GameState. Menu;
    else if (pushedRestartLevelButton)
    {
        ResetLevel();
        state = GameState.Gameplay;
    }
}

void DrawMenu(GameTime deltaTime)
{
    // Отрисовка меню, кнопок и т.д.
}

void DrawGameplay(GameTime deltaTime)
{
    // Отрисовка игровых объектов, счета и т.д.

void DrawEndOfGame(GameTime deltaTime)
{
    // Отрисовка результатов, кнопок и т.д.
}

Заключение


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

От себя хотелось бы посоветовать следующее: обязательно заканчивайте работу над начатой задумкой, доводите дело до конца. Определите для себя минимальный необходимый функционал игры или приложения — это позволит конкретизировать задачу и сократит объем работы до выпуска первой версии. Улучшать можно бесконечно, но оставьте «плюшки» для следующих выпусков, иначе можно погрязнуть в таком процессе и, в конечном счете, потерять интерес.

Стремитесь сделать качественный продукт. Это касается всех элементов игры: архитектуры проекта, кода, текстур, звуков и музыки. Для этого ищите профессиональные ответы на возникшие перед вами вопросы, читайте профильную литературу.

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

P.S. Что в итоге получилось:

image
Поделиться с друзьями
-->

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


  1. MaximChistov
    09.08.2016 21:55

    Неплохо пишите :)
    Радует, что хна стала аткуальнее


    1. Rikishi
      10.08.2016 08:59

      Спасибо)

      Да, с развитием Xamarin реинкарнация xna в виде Monogame действительно становится актуальной.


  1. VitaZheltyakov
    09.08.2016 23:09
    -3

    Чувствуется, что Monogame слизан с Flash. При том криво.

    А приведенный в статье способ масштабирования меня просто убил. Это же просто растяжение под размеры экрана, которое не учитывает пропорции.
    На мой взгляд, лучшим способом масштабирования является использование единого глобального коэффициента, вычисляемого как отношение максимального размера к текущему


    1. streeter12
      10.08.2016 00:50

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


    1. Rikishi
      10.08.2016 09:07

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

      Прошу прощения, но где вы в статье такое увидели?

      На мой взгляд, лучшим способом масштабирования является использование единого глобального коэффициента, вычисляемого как отношение максимального размера к текущему

      Фактически, как раз указанный вами способ и используется. Определяется единый коэффициент, который, в данном примере, равен отношению ширины экрана к ширине картинке. Затем текстуры масштабируются согласно полученному значению как по Х, так и по Y, сохраняя, тем самым, пропорции.
      При этом, если пропорции экрана и пропорции текстур не совпадают, то по бокам экрана остаются черные полосы (либо любой другой заданный цвет).

      Чувствуется, что Monogame слизан с Flash. При том криво.

      Не совсем понял, в чем, по вашему, это проявляется?


      1. VitaZheltyakov
        10.08.2016 10:32
        -1

        Да, правда ваша — пропорции сохраняются.

        Архитектура и подходы Monogame (показанные) аналогичны Flash


        1. Ununtrium
          10.08.2016 13:10

          Monodevelop «слизан» с xna


          1. Ununtrium
            17.08.2016 13:19
            +1

            Monogame, всмысле.


  1. vladsabenin
    10.08.2016 09:30

    В vs 2015 легкими движения руки можно скачать темплейт с репозитория MonoGame…


  1. marcor
    10.08.2016 15:33

    case по состоянию сцены для меня выглядит странно. Почему был выбран именно этот подход, в чём недостаток работы с абстрактным предком сцены?


    1. Rikishi
      10.08.2016 16:02

      Поясните, пожалуйста, что вы подразумеваете под работой с абстрактным предком сцены?


      1. marcor
        11.08.2016 06:01

        Ну, я так пишу обычно
        public abstract class AbstractScene
        {
            public abstract void Draw();
        
            public abstract void Update();
        }
        
        public class StringScene : AbstractScene
        {
            public StringScene(string text)
            {
                this.Text = text;
            }
        
            public string Text { get; set; }
        
            public override void Draw()
            {
                //...
            }
        
            public override void Update()
            {
                //...
            }
        }
        
        public class IntScene : AbstractScene
        {
            public IntScene(int number)
            {
                this.Number = number;
            }
        
            public int Number { get; set; }
        
            public override void Draw()
            {
                //...
            }
        
            public override void Update()
            {
                //...
            }
        }
        
        //...
        
        public class Game1 : Game
        {
            private AbstractScene scene;
        
            protected override void Initialize()
            {
                this.scene = new StringScene("Ima scene"); // инициализировать логично сценой меню или загрузки.
        
                base.Initialize();
            }
        
            protected override void Update(GameTime gameTime)
            {
                this.ReadKey(); // операции ввода и апдейт мира - две независимые операции. Интересно, кстати,
                this.scene.Update(); // почему они не разделены на уровне движка?
        
                base.Update(gameTime);
            }
        
            protected override void Draw(GameTime gameTime)
            {
                this.scene.Draw();
        
                base.Draw(gameTime);
            }
        
            private void ReadKey()
            {
                if (Keyboard.GetState().IsKeyDown(Keys.S))
                {
                    scene = new StringScene("Ima scene");
                }
        
                if (Keyboard.GetState().IsKeyDown(Keys.I))
                {
                    scene = new IntScene(1);
                }
            }
        }
        


        1. Rikishi
          11.08.2016 10:03

          Спасибо большое за ответ! Интересный подход, попробую в текущем проекте как раз :)

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

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

          А с этим полностью согласен.

          Вот здесь тоже интересный пример реализации смены сцен с использованием классов.