Пишем Android-игру на Xamarin+MonoGame (C#)

в 9:51, , рубрики: game development, mobile development, Mono и Moonlight, monogame, xamarin, xamarin studio, xna, Разработка под android

Пишем Android игру на Xamarin+MonoGame (C#)

Сегодня мы будем писать простенькую игру для Android на языке C# с использованием Xamarin и MonoGame.

  • Xamarin — это framework для кроссплатформенной разработки мобильных приложений (iOS, Android, Windows Phone) с использованием языка C#.
  • MonoGame — это кроссплатформенная реализация игрового движка XNA, позволяющая писать игры не только под Windows и Windows Phone (как изначально задумывалось Microsoft), но и массу других платформ, включая Android.

В качестве рабочей идеи было выбрано создание простой двумерной игры про PacMan-а, который, правда, будет не просто PacMan-ом, а 'сердитым PaсMan-ом', то есть поедающим привидения вместо желтых бонусов :)

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

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

Под катом много скриншотов, схем и кода) В конце урока приведена ссылка на GitHub с полными исходниками игры.

Вот такой вот «очень сердитый» (даже слишком) загрузочный экран удалось нарисовать:
Пишем Android игру на Xamarin+MonoGame (C#)

Работать будем в Xamarin Studion (фирменная IDE, свои впечатления об ее использовании я упоминал в статье на хабре). Так как до Xamarin 5 добраться пока не довелось, весь проект оформлен в 4.x. Впрочем, желающие могут без видимых проблем использовать VS2010/2012/2013.

Подготовка графики

Для начала подберем графику. В принципе, это шаг, на котором создается вся концепция 2d-игры. Несколько часов работы в Photoshop, создание шаблонов, и, ура, весь дизайн игры продуман и проработан! Хорошо, если в вашей команде этим занимается отдельный человек — дизайнер; мне же, выполняющему все работы сразу, всегда почему-то трудно переключаться между программированием и скрупулезной работой в графическом редакторе…

На уровне шаблона в Photoshop:

Оформление

Пишем Android игру на Xamarin+MonoGame (C#)

Понадобятся текстуры интерфейса — PacMan-а и его врагов (а также элементов интерфейса):
Пишем Android игру на Xamarin+MonoGame (C#) Пишем Android игру на Xamarin+MonoGame (C#) Пишем Android игру на Xamarin+MonoGame (C#) Пишем Android игру на Xamarin+MonoGame (C#)

Важный момент! Чтобы в дальнейшем не было проблем с масштабированием и пропорциями, всю графику адаптируем под одно разрешение. У меня выставлено 960x540. Это не значит, что при непосредственном написании кода все будет заточено под одно разрешение, напротив, позволит равномерно масштабировать согласно выставленному на устройстве (можете использовать свое, главное, чтобы вся графика создавалась в его пределах, подробности чуть дальше).

Все фоны игры выполним в одной цветовой гамме. Я выбрал синий цвет.

Меню начала игры и паузы будет выглядеть так:

Меню (rus)
Пишем Android игру на Xamarin+MonoGame (C#)

На английском языке:

Меню (rus)

Пишем Android игру на Xamarin+MonoGame (C#)

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

Прочие текстуры:

Остальные иллюстрации

Фон для меню победы:
Пишем Android игру на Xamarin+MonoGame (C#)

Фон для меню поражения:
Пишем Android игру на Xamarin+MonoGame (C#)

Фон самой игры:
Пишем Android игру на Xamarin+MonoGame (C#)

Границы экрана:
Пишем Android игру на Xamarin+MonoGame (C#)

Таким образом, на данном шаге мы собрали следующий перечень текстур:

  • background_game.png — фон игры
  • background_lose.png — фон поражения
  • background_win.png — фон победы
  • border.png — границы экрана (колючая проволока)
  • cross.png — значок, определяющий жизнь PacMan-а
  • pacman.png — текстура PacMan-а
  • pacman_back.png — отраженная по горизонтали текстура PacMan-а
  • target.png — приведение
  • target_bad.png — желтая шестеренка
  • ru_LoadingScreen.png — начальный фон после загрузки и фон паузы на русском языке
  • en_LoadingScreen.png — начальный фон после загрузки и фон паузы на английском языке
  • ru_splash.png — фон загрузочного экрана на русском языке
  • en_splash.png — фон загрузочного экрана на английском языке

Создание и настройка проекта

Создаем проект, указав «MonoGame for Android Application»:
Пишем Android игру на Xamarin+MonoGame (C#)

Переименовываем Game1 в PacManGame, а Activity1 в ActivityMain. В итоге должны получить вот такой вот «чистый» проект:
Пишем Android игру на Xamarin+MonoGame (C#)

Теперь начинаем его настраивать. Щелкаем правой кнопкой по надписи PacMan во главе списка слева (голубой картинке), выбираем пункт Options. В General выставляем Target Framework «Android 4.0», в Android Build на вкладке Advanced выставляем все галки (armeabi, armeabi-v7a, x86), в Android Application ставим Minimum и Target Android Version в «4.0», а также ставим галочки Internet и WriteExternalStorage (понадобится для того, чтобы сохранять результаты игры). Также выбираем значок icon в "@drawble/icon".

Пишем Android игру на Xamarin+MonoGame (C#)
Обратим внимание, что Version name установлена в 1.0 (в принципе, можете поменять на какую хотите, к примеру, 0.1b, это лишь отображаемая версия), а Version number равно 1. Значение Version number лучше не трогать, это по нему Google Play узнает, нужно ли обновлять игру, если в личный кабинет был загружен новый *.apk-файл. Таким образом, при каждом новом релизе нельзя использовать прошлые значения Version number.

В Resources/Drawble создаем папки drawable-hdpi, drawable-ldpi, drawable-mdpi, drawable-xhdpi, drawable-xxhdpi. В них нужно будет поместить значки в разных разрешениях, которые лучше всего сгенерировать используя App Icon Template. В drawble: 144x144, в drawable-hdpi: 72x72, в drawable-ldpi: 36x36, в drawble-mdpi: 72x72, в drawable-xhdpi: 96x96, в drawable-xhdpi: 144x144. Все значки должны быть названы просто icon.png.
Пишем Android игру на Xamarin+MonoGame (C#)
splash.png — загрузочный экран Xamarin, который будет показываться при открытии приложения в момент подгрузки виртуальной машины. На деле (в таком маленьком приложении) он не будет показываться, поэтому можно заменить его на то же изображение, что и в ru_splash/en_splash, но без надписей. Сами ru_splash/en_splash понадобятся при загрузке текстур.

В Assets создаем папку Content, в ней — папки en и ru (для удобства — чтобы разделять языки). Помещаем туда заготовленные картинки как показано на скриншоте (в дальнейшем их еще придется пополнять):
Пишем Android игру на Xamarin+MonoGame (C#)

Также создадим в папке Resources папки values, values-ru, values-en, values-uk, values-be, values-sr, а в них файл strings.xml. Как вы уже наверняка догадались, это файлы со строками, каждая на своем языке, туда поместим название приложения. Не смотря на то, что сама игра будет только на русском и английском, сделаем так, чтобы название могло отображаться на украинском, белорусском и сербском. Трудозатрат никаких (Google Translate на славянских языках работает довольно качественно), а вот увидеть приложение в Google Play и, в дальнейшем, значок с подписью на национальном языке, все равно приятно. Само приложение достаточно сделать на русском — сербы, белорусы и те, кто выставили в настройках на телефоне украинский, все равно русский язык понимают хорошо.

Код strings.xml:

strings.xml на разных языках

values:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="ApplicationName">Angry PacMan</string>
</resources>

values-ru:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="ApplicationName">Злой PacMan</string>
</resources>

values-uk:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="ApplicationName">Злий PacMan</string>
</resources>

values-be:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="ApplicationName">Злы PacMan</string>
</resources>

values-sr:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="ApplicationName">Љути PacMan</string>
</resources>

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

Разработка «внутренних» классов

Поставим задачей опосредовать логику игровых объектов и логику самого движка XNA/MonoGame. Пусть «внутренние» классы — классы, используемые для обработки внутреннего игрового процесса, то есть игровых объектов «игровой процесс», «игрок», «враг»; а «внешние» классы — классы, используемые для взаимодействия внутреннего игрового процесса и игрового движка. Остановимся подробнее на «внутренних».

Ниже приведу код с комментариями. Я постарался расписать его максимально подробно, если что-то не понятно, спрашивайте. Сразу обратим внимание, что все координаты указаны в пределах 960x540, то есть на уровне логики игровых объектов масштабирования игрового поля согласно текущему разрешению экрана телефона не существует).

Класс «игрок»:

Classes.Player.cs

using System;
using Microsoft.Xna.Framework;

namespace pacmangame
{
    // класс "игрок"
    class Player
    {
        // поля класса
        public float X { get; private set; } //координата X
        public float Y { get; private set; } //координата Y
        public int Direction; // направление движения (1 - вправо, 2 - влево, 3 - вверх, 4 - вниз)
        public float Speed { get; private set; } // скорость движения
        public float Angle { get; private set; } // угол наклона
        public int Lives; // число жизней
        public GameProcess GameProcess; // игровой процесс (устанавливается обратная связь)
        private int IncreaseSpeedCount; // промежуточная переменная для хранения счетчика увеличения скорости
        // конструктор класса - действия, которые осуществляются при его создании (инициализации)
        public Player(GameProcess importGameProcess)
        {
            // координаты создания - в левой части экрана
            X = 50;
            Y = 200;
            Lives = 5;
            Direction = 1; // направление движения устанавливаем "вправо"
            Angle = 0; // начальный угол равен 0 градусов
            Speed = 160f; // начальная скорость (потом можно изменить)
            GameProcess = importGameProcess; // установка обратной связи с игровым процессом
        }
        // метод обработки взаимодействия игрока с целями
        public void WorkWithTarget()
        {
            foreach (var enemy in GameProcess.Enemies)
            {
                // если игрок находится вблизи приведения, он его "съедает"
                if ((Math.Abs(X - enemy.Screenpos.X) < 40) && (Math.Abs(Y - enemy.Screenpos.Y) < 40))
                {
                    GameProcess.Score++;
                    enemy.IsAlive = false;
                }
            }
            foreach (var badenemy in GameProcess.BadEnemies)
            {
                // если игрок находится вблизи желтой шестеренки, эта шестеренка его ранит
                if ((Math.Abs(X - badenemy.Screenpos.X) < 40) && (Math.Abs(Y - badenemy.Screenpos.Y) < 40))
                {
                    Lives--; // уменьшить счетчик жизней
                    badenemy.IsAlive = false;
                }
            }
        }
        // метод обработки игрока
        public void Process(GameTime gameTime)
        {
            if (Lives < 0) // если число жизней меньше нуля
            {
                if (GameProcess.Score <= GameProcess.MaxScore) // если рекорд не был побит
                    GameProcess.LoseGame(); // поражение
                else GameProcess.WinGame(); // победа
            }
            //увеличение скорости до определенного предела
            IncreaseSpeedCount++;
			if (IncreaseSpeedCount >= 400) // спустя некоторое время скорость должна начать увеличиваться
            {
                if (Speed<340) Speed += 0.04f;
            }
            // выбор направления движения
            switch (Direction)
            {
                case 1: // движение вправо
                    {
                        Angle = 0;
                        X += Speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
                        break;
                    }
                case 2:  // движение влево
                    {
                        Angle = 0;
                        X -= Speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
                        break;
                    }
                case 3: // движение вверх
                    {
                        Angle = (float)(3.14 + 3.14 / 2);
                        Y -= Speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
                        break;
                    }
                case 4: // движение вниз
                    {
                        Angle = (float)3.14 / 2;
                        Y += Speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
                        break;
                    }
            }
            // проверка позиции игрока (если выходит за границы экрана - установить поражение)
            if ((X < 32) || (Y < 32) || (X > 960-32) || (Y > 540-32))
            {
                Lives = 5; // число жизней равно максимальному (не важно, были они потрачены или нет)
			if (GameProcess.Score <= GameProcess.MaxScore) // если рекорд не был побит
				GameProcess.LoseGame(); // поражение
			else GameProcess.WinGame(); // победа
            }
        }
    }
}

Класс «враг»:

Classes.Enemy.cs

using System;
using Microsoft.Xna.Framework;

namespace pacmangame
{
    // класс "враг"
    public class Enemy
    {
        public Vector2 Screenpos; // позиция на экране
        public Vector2 Center; // ось вращения
        public Vector2 Velocity; // векторы скорости
        public bool IsAlive; // была ли цель уничтожена
        public float Rotation; //поворот
        public float Speed;//скорость
        private Random Rnd; // счетчик случайных чисел
        private int IncreaseSpeedCount; // промежуточное поле для хранения счетчика увеличения скорости

        // конструктор класса - действия, которые осуществляются при его создании (инициализации)
        public Enemy(int x,int y)
        {
            Speed = 4;
            Center.X = 40;
            Center.Y = 40;
            IsAlive = true;
            Screenpos.X = x; 
            Screenpos.Y = y;
            Velocity = new Vector2(20, 20);
        }

        //  метод обработки целей
        public void Process(bool enableSpeedUp, GameTime gameTime) 
        {
            Rnd = new Random(); // инициализация счетчика случайных чисел
            if (enableSpeedUp) // если допустимо ускорение 
            {
                //увеличение скорости до определенного предела
                IncreaseSpeedCount++;
				if (IncreaseSpeedCount >= 400) // спустя некоторое время скорость должна начать увеличиваться
                {
                    if (Speed<30) Speed += 0.001f;
                }
            }
            else Speed = 2; // в противном случае - скорость постоянна
            if (!IsAlive) // если цель была уничтожена - новое появление на случайных позициях экрана
            {
                    IsAlive = true; // цель не была уничтожена
                    switch (Rnd.Next(1, 5)) // выбор координат в зависимости от ситуации
                    {
                        case 1:
                            Screenpos.X = Rnd.Next(40,100);
                            Screenpos.Y = 40;
                            break;
                        case 2:
                            Screenpos.X = 550;
                            Screenpos.Y = Rnd.Next(40, 100);
                            break;
                        case 3:
                            Screenpos.X = 40;
                            Screenpos.Y = Rnd.Next(400,500);
                            break;
                        case 4:
                            Screenpos.X = Rnd.Next(500, 600);
                            Screenpos.Y = 450;
                            break;
                }
            }
            Screenpos += Speed * Velocity * (float)gameTime.ElapsedGameTime.TotalSeconds; // движение по экрану
            Rotation += ((Rnd.Next(1, 2))) * (float)gameTime.ElapsedGameTime.TotalSeconds; // изменение угла
            // отталкивание от края экрана
            if (Screenpos.X < 0 + Center.X) Velocity.X = -Velocity.X;
            if (Screenpos.X > 960 - Center.X) Velocity.X = -Velocity.X;
            if (Screenpos.Y < 0 + Center.X) Velocity.Y = -Velocity.Y;
            if (Screenpos.Y > 540 - Center.X) Velocity.Y = -Velocity.Y;
        }
    }
}

Класс «игровой процесс»:

Classes.GameProcess.cs

using System.Collections.Generic;

namespace pacmangame
{
    // класс "Игровой процесс"
    class GameProcess
    {
        public bool IsWin; // была ли победа (к полю можно обратиться извне, но нельзя изменить извне)
        public bool IsLose; // было ли поражение (к полю можно обратиться извне, но нельзя изменить извне)
        public bool IsGame; // идет ли игровой процесс (к полю можно обратиться извне, но нельзя изменить извне)
        public bool IsPause; // включена ли пауза
		public int Score; // текущий счет 
        public int MaxScore = 0; // наилучший счет (изначально равен нулю)
		ClassScoreManager ClassScoreManager = new ClassScoreManager(); // менеджер счета
		public List<Enemy> Enemies = new List<Enemy>(); // привидения
		public List<Enemy> BadEnemies = new List<Enemy>(); // желтые шестеренки
        // инициализация класса
		public GameProcess()
        {
            IsWin = false; // победы не было
            IsLose = false; // поражения не было
            IsGame = false; // игровой процесс еще не идет
            IsGame = false; // пауза не включена

			// установка рекорда
			ClassScoreManager = ClassScoreManager.ReadScores();
			MaxScore = ClassScoreManager.Score.Value;

            // добавление врагов
            Enemies.Add(new Enemy(100, 300));
            Enemies.Add(new Enemy(500, 400));
            Enemies.Add(new Enemy(300, 400));
            Enemies.Add(new Enemy(200, 350));
            Enemies.Add(new Enemy(600, 100));
            Enemies.Add(new Enemy(150, 200));
            Enemies.Add(new Enemy(400, 150));
            // добавление шестеренок
            BadEnemies.Add(new Enemy(200, 500));
            BadEnemies.Add(new Enemy(610, 200));
            BadEnemies.Add(new Enemy(200, 510));
            BadEnemies.Add(new Enemy(500, 350));
            BadEnemies.Add(new Enemy(100, 420));
        }
        // установка новой игры (подразумевает отрисовку начального экрана)
        public void NewGame()
        {
            IsWin = false;
            IsLose = false;
            IsGame = false;     
			ClassScoreManager.ReadScores();
        }
        // установка победы
        public void WinGame()
        {
            IsWin = true;
            IsLose = false;
            IsGame = false;
			ClassScoreManager.Score.Value = Score;
			ClassScoreManager.WriteScores();
        }
        // установка поражения
        public void LoseGame()
        {
            IsWin = false;
            IsLose = true;
            IsGame = false;
        }
    }
}

В Classes.GameProcess.cs используется класс ClassScoreManager, предназначенный для управления счетом (считывание из системного хранилища Android) и запись. О нем разговор пойдет в дальнейшем.

Разработка «внешних» классов

Рассмотрим Game.cs — основной класс «игра» (содержит все визуальные объекты и методы обработки игрового процесса). Тем, кто знаком с XNA Game Studio и используемой логикой, будет проще, поскольку в MonoGame ничего не изменилось. Если говорить кратко:

  • public PacManGame() — инициализация класса (то есть все действия, выполняемые при создании класса «игра»)
  • protected override void LoadContent() — загрузка контента (всех текстур, также понадобится загружать шрифт)
  • protected override void Update(GameTime gameTime) — обновление игрового процесса, то есть все действия, выполняемые в единицу времени (кроме отрисовки)
  • protected override void Draw(GameTime gameTime) — отрисовка визуальных объектов в единицу времени (также может содержать действия, т.е. Update можно не использовать, но с этим осторожнее: к примеру, обработка управления идет только через Update)

Одна из главных трудностей, с которой приходится сталкиваться в процессе разработки игры на MonoGame, это адаптация под различные разрешения экрана. Графика была заготовлена в разрешении 960x540, соотношение сторон 16:9, при этом у игрока может быть, к примеру, 16:10, или какое-нибудь другое, нестандартное соотношение сторон экрана. Все это следует учесть.

Для этого я ввел переменные const int NominalWidth = 960, const int NominalHeight = 540, float Dx, а также int CurrentWidth и int CurrentHeight. Что это значит? Что в CurrentWidth/CurrentHeight нужно считать текущую высоту и ширину экрана устройства, и посчитать соотношение с ScreenWidth и ScreenHeight, получив результат в Dx. А дальше отрисовывать всю графику с увеличением в Dx раз.

Не по стандартам Android? Определенно да. Тем не менее, без привлечения сторонних разработок, MonoGame не позволяет более гармоничным способом адаптировать приложение к разным экранам.

Таким образом, получается следующий код инициализации класса «игра»:

        public PacManGame()
        {
            // инициализация графики
            Graphics = new GraphicsDeviceManager(this);
            var metric = new Android.Util.DisplayMetrics();
            Activity.WindowManager.DefaultDisplay.GetMetrics(metric);
            // установка параметров экрана
            Graphics.IsFullScreen = true;
            Graphics.PreferredBackBufferWidth = metric.WidthPixels; 
            Graphics.PreferredBackBufferHeight = metric.HeightPixels; 
            CurrentWidth = Graphics.PreferredBackBufferWidth;
            CurrentHeigth = Graphics.PreferredBackBufferHeight;
            Graphics.SupportedOrientations = DisplayOrientation.LandscapeLeft | DisplayOrientation.LandscapeRight;  
            // обновить параметры экрана
            UpdateScreenAttributies();
            // дальнейшие действия
            ...
        }

Обновление параметров экрана и коррекция координат X и Y (также учитывается тот факт, что разрешение экрана может быть не 16:9, а любое другое, в таком случае координаты центрируются по середине):

		// обновление параметров экрана
        public void UpdateScreenAttributies()
        {
            Dx = (float) CurrentWidth/NominalWidth;
            Dy = (float) CurrentHeigth/NominalHeight;

            NominalHeightCounted = CurrentHeigth/Dx;
            NominalWidthCounted = CurrentWidth/Dx;

            int check = Math.Abs(CurrentHeigth - CurrentWidth/16*9);
            if (check > 10)
                deltaY = (float) check/2; // недостающее расстояние до 16:9 по п оси Y (в абсолютных координатах)
            deltaY_1 = -(CurrentWidth/16*10 - CurrentWidth/16*9)/2f;

            YTopBorder = -deltaY/Dx; // координата точки в левом верхнем углу (в вируальных координатах)
            YBottomBorder = NominalHeight + (180); // координата точки в нижнем верхнем углу (в виртуальных координатах)
        }

		// коррекция координаты X
        public static float AbsoluteX(float x)
        {
            return x*Dx;
        }

		// коррекция координаты Y
        public static float AbsoluteY(float y)
        {
            return y*Dx + deltaY;
        }

Код отрисовки:


        // отрисовка визуальных объектов (выполняется в каждый конкретный момент времени)
        protected override void Draw(GameTime gameTime)
		{
			/* Задача данного метода - отрисовывать графически объекты.
               То есть: меню, надписи, текстуры игры, объекты интерфейса и т.п.   */
			GraphicsDevice.Clear (Color.AliceBlue); // заполнить фон
			// установить последовательный порядок отрисовки объектов
			SpriteBatch.Begin (SpriteSortMode.Deferred, BlendState.NonPremultiplied);
			// отрисовка всего необходимого
			...
			// к примеру - текстура фона
			SpriteBatch.Draw (TextureBackground, new Vector2 (AbsoluteX (0), AbsoluteY (0)),
							new Rectangle (0, 0, TextureBackground.Width, TextureBackground.Height), Color.White,
							0, new Vector2 (0, 0), 1 * Dx, SpriteEffects.None, 0); // отрисовка фона
			// обязательно используем AbsoluteX, AbsoluteY и 1*Dx (то есть просто Dx; 	
			// 2 * Dx - значит дополнительное увеличение в 2 раза) в поле Scale		
			...
			// отрисовка рамок
			DrawRectangle (new Rectangle (-100, -100, CurrentWidth + 100 + 100, 100 + (int)deltaY),Color.Black);
			DrawRectangle (new Rectangle (-100, CurrentHeigth - (int)deltaY, CurrentWidth + 100 + 100,
 						(int)deltaY + (int)deltaY_1 + 100), Color.Black);	
			}
			SpriteBatch.End (); // прервать отрисовку на данном этапе
			base.Draw (gameTime); // обновить счетчик игрового времени
		}
		
        // отрисовка прямоугольника	(по заданым координатам, с заданным цветом)
        public void DrawRectangle(Rectangle coords, Color color)
        {
            var rect = new Texture2D(GraphicsDevice, 1, 1);
            rect.SetData(new[] {color});
            SpriteBatch.Draw(rect, coords, color);
        }

Схема отрисовки:
Пишем Android игру на Xamarin+MonoGame (C#)
Альтернативных вариантов можно придумать много. Например, заранее подготовить всю графику в 16:10 и использовать черные рамки по вертикали если экран 16:10, либо предусмотреть обрезку и избежать рамок. В другом своем проекте, где все нужно сделать совсем без недочетов (так, чтобы прямо действительно совсем), я как раз использую вариант без рамок — он, к сожалению, более трудоемкий.

Рассмотрим простейшую функцию отрисовки фона:

  • SpriteBatch.Draw (TextureBackground, new Vector2 (AbsoluteX (0), AbsoluteY (0)), new Rectangle (0, 0, TextureBackground.Width, TextureBackground.Height), Color.White, 0, new Vector2 (0, 0), 1 * Dx, SpriteEffects.None, 0);
  • TextureBackgroud — сама текстура (Texture2D)
  • new Vector2(AbsoluteX (0), AbsoluteY (0)) — установка координат (обязательно прогонять через наши методы AbsoluteX и AbsoluteY!)
  • new Rectangle (0, 0, TextureBackground.Width, TextureBackground.Height) — установка обрезки текстуры (без обрезки: левый верхний угол имеет координаты [0;0], а правый нижний угол координаты [ширина текстуры; высота текстуры])
  • Color.White — наложение цветового слоя на текстуру
  • new Vector2 (0, 0) — точка, относительно которой происходит отрисовка текстуры, в данном случае это верхний левый угол [0;0] (если бы нам был нужен центр, то можно указать [TextureBackground.Width/2; TextureBackground.Height/2])
  • 1 * Dx — масштабирование текстуры (т.е. любая наша текстура должна увеличиваться в Dx раз вне зависимости от того, нужно ли дополнительное масштабирование, к примеру 2 * Dx, 3 * Dx, 0.5f * Dx ...)

Теперь рассмотрим процесс работы с кнопками. Для них я создал специальный класс Game.Buttons.cs, который с минимальными изменениями использую во всех MonoGame-проектах.

Game.Buttons.cs

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input.Touch;

namespace pacmangame
{
    partial class PacManGame : Game
    {
        public class Button
        {
            public int YUp;
            public int YDown;
            public int XRight;
            public int XLeft;
            public Texture2D TextureButton;
            public Texture2D TextureButtonLight;

            private TouchCollection Touches;
            private bool IsPressed;
            public bool IsEnabled;

            public Button()
            {
				XRight = -100;
				XLeft = -100;
				YUp = -100;
				YDown = -100;
                IsPressed = false;
                IsEnabled = false;
            }

            public void Update(int xLeft, int yUp)
            {
                XRight = XLeft+TextureButton.Width;
                XLeft = xLeft;
                YUp = yUp;
                YDown = yUp + TextureButton.Height;
            }

            public void Process(SpriteBatch spriteBatch)
            {
                Touches = TouchPanel.GetState();
                if (Touches.Count == 1)
                {
                    if (!IsPressed)
                    {
                        spriteBatch.Draw(TextureButton, new Vector2(AbsoluteX(XLeft), AbsoluteY(YUp)),
                                 new Rectangle(0, 0, TextureButton.Width, TextureButton.Height), Color.White, 0,
                                 new Vector2(0, 0),
                                 Dx, SpriteEffects.None, 0);
                    }
                    if ((Touches[0].Position.X > AbsoluteX(XLeft)) && (Touches[0].Position.X < AbsoluteX(XRight+10)) &&
                        (Touches[0].Position.Y > AbsoluteX(YUp)) && (Touches[0].Position.Y < AbsoluteY(YDown+10)))
                    {
                        spriteBatch.Draw(TextureButtonLight, new Vector2(AbsoluteX(XLeft), AbsoluteY(YUp)),
                                         new Rectangle(0, 0, TextureButtonLight.Width, TextureButtonLight.Height), Color.White, 0,
                                         new Vector2(0, 0),
                                         Dx, SpriteEffects.None, 0);
                        IsPressed = true;
                    }
                    else
                    {
                        if (IsPressed) IsPressed = false;
                    }
                }
                else
                {
                    spriteBatch.Draw(TextureButton, new Vector2(AbsoluteX(XLeft), AbsoluteY(YUp)),
                                 new Rectangle(0, 0, TextureButton.Width, TextureButton.Height), Color.White, 0,
                                 new Vector2(0, 0),
                                 Dx, SpriteEffects.None, 0);
                }
                if ((IsPressed) && (Touches.Count == 0))
                {
                    IsEnabled = true;
                }
            }
            public void Reset()
            {
                IsPressed = false;
                IsEnabled = false;
            }
        }

    }
}

Суть такова: кнопка может существовать в двух режимах — нажатом и не нажатом, для каждого используется своя текстура. Пусть не нажатый режим использует текстуру TextureButton, а нажатый — TextureButtonLight (можно назвать TextureButtonPressed, просто в прошлом проекте у меня кнопки «подсвечивались» при нажатии, отсюда осталось Light). Сперва нужно обработать отрисовку кнопки через Process и обновление ее логики через Update, затем проверить, была ли она нажата.

На самом деле цепочка действий немного сложнее: кнопка «зажата», значит IsPressed становится true и вместо TextureButton отрисовывается TextureButtonLight, затем может быть так, что игрок передвинет с нее палец и отпустит (не захотел нажимать, либо случайно задел и сразу сдвинул палец), тем самым IsPressed снова станет false, а вот если игрок отпустит кнопку и в предыдущий момент времени палец будет на ней, то IsEnabled станет true и можно выполнять все действия по непосредственному назначению кнопки).

На примере кнопки паузы:

                ButtonPause.Process (SpriteBatch); // отрисовка кнопки
                ButtonPause.Update (830, 484); // выполнение всех проверок, связанных с кнопкой (нажата/не нажата и т.п.)
                if (ButtonPause.IsEnabled) // кнопка была нажата и отпущена 
                { 
                      ButtonPause.Reset ();   // обнулить все настройки кнопки
                      GameProcess.IsPause = true; // выполнить необходимое действие - установить паузу
                      // другие возможные действия, к которым приводит нажатие кнопки ....
                }

Интереснее пример с кнопкой посещения сайта:

                ButtonVisitSite.Process(SpriteBatch); // отрисовка кнопки
                ButtonVisitSite.Update(741, 475); // выполнение всех проверок, связанных с кнопкой (нажата/не нажата и т.п.)
                if (ButtonVisitSite.IsEnabled)  // кнопка была нажата и отпущена 
                {
                      ButtonVisitSite.Reset(); // обнулить все настройки кнопки
                      // открыть браузер и нужную страницу
                      var uri = Android.Net.Uri.Parse("http://адрес");
                      var intent = new Intent(Intent.ActionView, uri);
                      Activity.StartActivity(intent);
                 }

Для всех кнопок нужно подготовить и загрузить текстуры. В процессе было выяснено, что понадобятся следующие кнопки: «Начать игру», «Новая игра», «Пауза», «Продолжить», «Сыграть заново», «Выход» (+ссылка на сайт, отображающаяся в меню).И, что не маловажно, кнопки управления со стрелками, которые будут располагаться по экрану.

Получается вот такая картина:
Пишем Android игру на Xamarin+MonoGame (C#)

В конце урока есть ссылка на GitHub, там собран весь проект, включая данные текстуры. Сначала создавались текстуры кнопок в нажатом состоянии (%name%Pressed.png), затем путем уменьшения в 0.8-0.9 раз и повышением прозрачности получалась обычная текстур. То есть изначально игрок видит чуть уменьшенные полупрозрачные надписи, при нажатии они увеличиваются и имеют четкие очертания. Помним: текстуры с префиксом «ru_» кладем в папку «ru», а текстуры с префиксом «en_» в папку «en», не забывая при том, что нужно добавлять их через контент-менеджер (панель слева).

Теперь интересный момент — загрузка текстур! Объявим в классе Game следующие поля:

        // текстуры
        public Texture2D TextureBackground; // фон
        public Texture2D TextureBorder; // граница фона
        public Texture2D TextureBackgroundLose; // фон меню поражения
        public Texture2D TextureBackgroundWin; // фон меню победы
        public Texture2D TextureBackgroundLoad; // фон меню загрузки
        public Texture2D TexturePlayer; // игрок
        public Texture2D TexturePlayerBack; // игрок (отраженный)
        public Texture2D TextureTarget; // цель
        public Texture2D TextureTargetBad; // шестеренка
        public Texture2D TextureCross; // счетчик жизней
        public Texture2D Splash; // загрузочный экран
        public SpriteFont Font; // шрифт
        // кнопки
        public Button ButtonUp = new Button();
        public Button ButtonDown = new Button();
        public Button ButtonRight = new Button();
        public Button ButtonLeft = new Button();
        public Button ButtonReplay = new Button();
        public Button ButtonNewGame = new Button();
        public Button ButtonPause = new Button();
        public Button ButtonResumeGame = new Button();
        public Button ButtonStartNewGame = new Button();
        public Button ButtonExit = new Button();
        public Button ButtonVisitSite = new Button();

По логике MonoGame/XNA есть один метод для загрузки LoadContent(), автоматически вызывающийся в самом начале. У него есть серьезный недостаток: когда текстур много, загрузка происходит довольно долго, игрок видит черный экран, что совсем нехорошо. Поэтому давайте сначала загрузим текстуру загрузочного экрана Splash, покажем ее, и лишь после этого начнем загружать остальные текстуры.

Для этого введем:

        public bool SplashShown = true;
        public bool SplashHide = false;

Метод LoadContent примет вид:

        // загрузка контента
        protected override void LoadContent()
        {
            SpriteBatch = new SpriteBatch(GraphicsDevice); // инициализация графики и загрузка текстур
			Content.RootDirectory = "Content/"+Language;
			Splash = Content.Load<Texture2D>(Language+"_"+"splash");
        }

Что за странная переменная Language? Это строковое значение, равное «en» или «ru», про него упомянем чуть ниже. Таким образом, изначально загружается одна лишь текстура Splash. Тогда в методе Draw необходимо реализовать следующую логику:

            if (SplashShown)
            {
                // действия, если показан загрузочный экран 
                if (SplashHide)
                {
                    LoadData(Language); // загрузка данных (основных игровых текстур)
                    // выключить загрузочный экран
                    SplashShown = false;
                    SplashHide = true;
                }
                else
                {
                    SplashHide = true;
                }
                // отрисовать загрузочный экран
                SpriteBatch.Draw(Splash, new Vector2(AbsoluteX(0), AbsoluteY(0)),
                                 new Rectangle(0, 0, Splash.Width, Splash.Height),
                                 Color.White, 0,
                                 new Vector2(0, 0), Dx, SpriteEffects.None, 0);
            }
            else
            {
               // действия при обычной игре
               ...
            }

Метод LoadData:

        public void LoadData(string locale)
        {
            Content.RootDirectory = "Content/";
            TextureBackground = Content.Load<Texture2D>("background_game");
            TextureBorder = Content.Load<Texture2D>("border");
            TextureBackgroundLose = Content.Load<Texture2D>("background_lose");
            TextureBackgroundWin = Content.Load<Texture2D>("background_win");
            TexturePlayer = Content.Load<Texture2D>("pacman");
            TexturePlayerBack = Content.Load<Texture2D>("pacman_back");
            TextureTarget = Content.Load<Texture2D>("target");
            TextureTargetBad = Content.Load<Texture2D>("target_bad");
            TextureCross = Content.Load<Texture2D>("cross");
            Font = Content.Load<SpriteFont>("Font");
            ButtonUp.TextureButton = Content.Load<Texture2D>("ButtonUp");
            ButtonUp.TextureButtonLight = Content.Load<Texture2D>("ButtonUpPressed");
            ButtonDown.TextureButton = Content.Load<Texture2D>("ButtonDown");
            ButtonDown.TextureButtonLight = Content.Load<Texture2D>("ButtonDownPressed");
            ButtonLeft.TextureButton = Content.Load<Texture2D>("ButtonLeft");
            ButtonLeft.TextureButtonLight = Content.Load<Texture2D>("ButtonLeftPressed");
            ButtonRight.TextureButton = Content.Load<Texture2D>("ButtonRight");
            ButtonRight.TextureButtonLight = Content.Load<Texture2D>("ButtonRightPressed");
            ButtonVisitSite.TextureButton = Content.Load<Texture2D>("ButtonSite");
            ButtonVisitSite.TextureButtonLight = Content.Load<Texture2D>("ButtonSitePressed");
            Content.RootDirectory = "Content/" + locale;
            TextureBackgroundLoad = Content.Load<Texture2D>(locale + "_" + "LoadingScreen");
            ButtonReplay.TextureButton = Content.Load<Texture2D>(locale + "_" + "ButtonPlayAgain");
            ButtonReplay.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "ButtonPlayAgainPressed");
            ButtonNewGame.TextureButton = Content.Load<Texture2D>(locale + "_" + "StartGame");
            ButtonNewGame.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "StartGamePressed");
            ButtonPause.TextureButton = Content.Load<Texture2D>(locale + "_" + "PauseButton");
            ButtonPause.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "PauseButtonPressed");
            ButtonResumeGame.TextureButton = Content.Load<Texture2D>(locale + "_" + "ButtonResume");
            ButtonResumeGame.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "ButtonResumePressed");
            ButtonStartNewGame.TextureButton = Content.Load<Texture2D>(locale + "_" + "ButtonNewGame");
            ButtonStartNewGame.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "ButtonNewGamePressed");
            ButtonExit.TextureButton = Content.Load<Texture2D>(locale + "_" + "ButtonExit");
            ButtonExit.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "ButtonExitPressed");
        }

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

Теперь по поводу использованной переменной Language. Как мы уже упомянули, она имеет значение «ru» или «en». В инициализации PacManGame нужно осуществить следующие действия:

            string strlocale = Locale.Default.ToString();
            strlocale = strlocale.Substring(0, 2);
            if (strlocale.Equals("ru") || strlocale.Equals("be") || strlocale.Equals("uk") || strlocale.Equals("sr") ||
                strlocale.Equals("kz"))
            {
                Language = "ru";
                strScore = "Счет: ";
                strRecord = "Рекорд: ";
                strScoreAmount = "Число очков: ";
                strRecordString = "Рекорд ";
                strRecordNotReached = " не был побит.";
                strPacmanInjured = "Pacman был ранен о границы поля...";
                strNewRecord = "Поставлен рекорд ";
            }
            else
            {
                Language = "en";
                strScore = "Score: ";
                strRecord = "Record: ";
                strScoreAmount = "Reached score: ";
                strRecordString = "Record ";
                strRecordNotReached = " was not reached.";
                strPacmanInjured = "Pacman was injured by field border...";
                strNewRecord = "New record reached: ";
            }

То есть: считываем из системы текущий язык, и в зависимости от полученного значения присваиваем переменную Language и ряд других (такая длинная процедура с locale.Substring нужна для того, чтобы отделить от всех многочисленных en-US, en-GB и остальных просто частицу en). В принципе, то же самое можно было реализовать через strings.xml, но поскольку используемых строк немного, а игра всего лишь на двух языках, то можно обойтись и простым присваиванием прямо в коде.

Строки отрисовываются шрифтом Font.xnb. MonoGame поддерживает только скомпилированные *.xnb, а не XNA-шные *.spritefont, так что пришлось отдельно компилировать через XNA (см. оба файла на GitHub).

Полный код Game.cs, с комментариями:

Game.cs

using System;
using System.Globalization;
using Android.Content;
using Java.Util;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace pacmangame
{
    /* Основной класс "игра". Содержит все визуальные объекты и методы обработки игрового процесса. */

    partial class PacManGame : Game
    {
        // системные объекты графики
        private readonly GraphicsDeviceManager Graphics;
        private SpriteBatch SpriteBatch;
        // текстуры
        private Texture2D TextureBackground; // фон
        private Texture2D TextureBorder; // фон
        private Texture2D TextureBackgroundLose; // фон меню поражения
        private Texture2D TextureBackgroundWin; // фон меню победы
        private Texture2D TextureBackgroundLoad; // фон меню загрузки
        private Texture2D TexturePlayer; // игрок
        private Texture2D TexturePlayerBack; // игрок (отраженный)
        private Texture2D TextureTarget; // цель
        private Texture2D TextureTargetBad; // шестеренка
        private Texture2D TextureCross; // счетчик жизней
        private SpriteFont Font;
        // кнопки
        public Button ButtonUp = new Button();
        public Button ButtonDown = new Button();
        public Button ButtonRight = new Button();
        public Button ButtonLeft = new Button();
        public Button ButtonReplay = new Button();
        public Button ButtonNewGame = new Button();
        public Button ButtonPause = new Button();
        public Button ButtonResumeGame = new Button();
        public Button ButtonStartNewGame = new Button();
        public Button ButtonExit = new Button();
        public Button ButtonVisitSite = new Button();

        // используемые объекты
        private Player Player; // игрок (объект)
        private GameProcess GameProcess = new GameProcess(); // игровой процесс (объект)

        // настройки отрисовки
        public static float Dx = 1f;
        public static float Dy = 1f;
        private static int NominalWidth = 960;
        private static int NominalHeight = 540;
        private static float NominalWidthCounted;
        private static float NominalHeightCounted;
        private static int CurrentWidth;
        private static int CurrentHeigth;
        private static float deltaY = 0;
        private static float deltaY_1 = 0;
        public static float YTopBorder;
        public static float YBottomBorder;
        // строки
        public string Language = "en";
        public string strScore = "";
        public string strRecord = "";
        public string strScoreAmount = "";
        public string strRecordString = "";
        public string strRecordNotReached = "";
        public string strPacmanInjured = "";
        public string strNewRecord = "";
        // загрузочный экран
        public Texture2D Splash;
        public bool SplashShown = true;
        public bool SplashHide = false;

        // обновление параметров экрана
        public void UpdateScreenAttributies()
        {
            Dx = (float) CurrentWidth/NominalWidth;
            Dy = (float) CurrentHeigth/NominalHeight;

            NominalHeightCounted = CurrentHeigth/Dx;
            NominalWidthCounted = CurrentWidth/Dx;

            int check = Math.Abs(CurrentHeigth - CurrentWidth/16*9);
            if (check > 10)
                deltaY = (float) check/2; // недостающее расстояние до 16:9 по п оси Y (в абсолютных координатах)
            deltaY_1 = -(CurrentWidth/16*10 - CurrentWidth/16*9)/2f;

            YTopBorder = -deltaY/Dx; // координата точки в левом верхнем углу (в вируальных координатах)
            YBottomBorder = NominalHeight + (180); // координата точки в нижнем верхнем углу (в виртуальных координатах)
        }

        public void DrawRectangle(Rectangle coords, Color color)
        {
            var rect = new Texture2D(GraphicsDevice, 1, 1);
            rect.SetData(new[] {color});
            SpriteBatch.Draw(rect, coords, color);
        }

        // калибровка координаты X
        public static float AbsoluteX(float x)
        {
            return x*Dx;
        }

        // калибровка координаты Y
        public static float AbsoluteY(float y)
        {
            return y*Dx + deltaY;
        }

        // инициализация класса "игра"
        public PacManGame()
        {
            // инициализация графики
            Graphics = new GraphicsDeviceManager(this);
            var metric = new Android.Util.DisplayMetrics();
            Activity.WindowManager.DefaultDisplay.GetMetrics(metric);
            // установка параметров экрана

            Graphics.IsFullScreen = true;
            Graphics.PreferredBackBufferWidth = metric.WidthPixels;
            Graphics.PreferredBackBufferHeight = metric.HeightPixels;
            CurrentWidth = Graphics.PreferredBackBufferWidth;
            CurrentHeigth = Graphics.PreferredBackBufferHeight;
            Graphics.SupportedOrientations = DisplayOrientation.LandscapeLeft | DisplayOrientation.LandscapeRight;

            UpdateScreenAttributies();

            string strlocale = Locale.Default.ToString();
            strlocale = strlocale.Substring(0, 2);
            if (strlocale.Equals("ru") || strlocale.Equals("be") || strlocale.Equals("uk") || strlocale.Equals("sr") ||
                strlocale.Equals("kz"))
            {
                Language = "ru";
                strScore = "Счет: ";
                strRecord = "Рекорд: ";
                strScoreAmount = "Число очков: ";
                strRecordString = "Рекорд ";
                strRecordNotReached = " не был побит.";
                strPacmanInjured = "Pacman был ранен о границы поля...";
                strNewRecord = "Поставлен рекорд ";
            }
            else
            {
                Language = "en";
                strScore = "Score: ";
                strRecord = "Record: ";
                strScoreAmount = "Reached score: ";
                strRecordString = "Record ";
                strRecordNotReached = " was not reached.";
                strPacmanInjured = "Pacman was injured by field border...";
                strNewRecord = "New record reached: ";
            }
            var locale = new Locale(Language); // languageIso is locale string
            Locale.Default = locale;
            var config = new Android.Content.Res.Configuration {Locale = locale};
            Activity.Resources.UpdateConfiguration(config, Activity.Resources.DisplayMetrics);

        }

        // загрузка контента
        protected override void LoadContent()
        {
            SpriteBatch = new SpriteBatch(GraphicsDevice); // инициализация графики и загрузка текстур
            Content.RootDirectory = "Content/" + Language;
            Splash = Content.Load<Texture2D>(Language + "_" + "splash");

        }

        public void LoadData(string locale)
        {
            Content.RootDirectory = "Content/";

            TextureBackground = Content.Load<Texture2D>("background_game");
            TextureBorder = Content.Load<Texture2D>("border");
            TextureBackgroundLose = Content.Load<Texture2D>("background_lose");
            TextureBackgroundWin = Content.Load<Texture2D>("background_win");
            TexturePlayer = Content.Load<Texture2D>("pacman");
            TexturePlayerBack = Content.Load<Texture2D>("pacman_back");
            TextureTarget = Content.Load<Texture2D>("target");
            TextureTargetBad = Content.Load<Texture2D>("target_bad");
            TextureCross = Content.Load<Texture2D>("cross");
            Font = Content.Load<SpriteFont>("Font");

            ButtonUp.TextureButton = Content.Load<Texture2D>("ButtonUp");
            ButtonUp.TextureButtonLight = Content.Load<Texture2D>("ButtonUpPressed");
            ButtonDown.TextureButton = Content.Load<Texture2D>("ButtonDown");
            ButtonDown.TextureButtonLight = Content.Load<Texture2D>("ButtonDownPressed");
            ButtonLeft.TextureButton = Content.Load<Texture2D>("ButtonLeft");
            ButtonLeft.TextureButtonLight = Content.Load<Texture2D>("ButtonLeftPressed");
            ButtonRight.TextureButton = Content.Load<Texture2D>("ButtonRight");
            ButtonRight.TextureButtonLight = Content.Load<Texture2D>("ButtonRightPressed");

            ButtonVisitSite.TextureButton = Content.Load<Texture2D>("ButtonSite");
            ButtonVisitSite.TextureButtonLight = Content.Load<Texture2D>("ButtonSitePressed");

            Content.RootDirectory = "Content/" + locale;

            TextureBackgroundLoad = Content.Load<Texture2D>(locale + "_" + "LoadingScreen");
            ButtonReplay.TextureButton = Content.Load<Texture2D>(locale + "_" + "ButtonPlayAgain");
            ButtonReplay.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "ButtonPlayAgainPressed");

            ButtonNewGame.TextureButton = Content.Load<Texture2D>(locale + "_" + "StartGame");
            ButtonNewGame.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "StartGamePressed");

            ButtonPause.TextureButton = Content.Load<Texture2D>(locale + "_" + "PauseButton");
            ButtonPause.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "PauseButtonPressed");

            ButtonResumeGame.TextureButton = Content.Load<Texture2D>(locale + "_" + "ButtonResume");
            ButtonResumeGame.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "ButtonResumePressed");
            ButtonStartNewGame.TextureButton = Content.Load<Texture2D>(locale + "_" + "ButtonNewGame");
            ButtonStartNewGame.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "ButtonNewGamePressed");
            ButtonExit.TextureButton = Content.Load<Texture2D>(locale + "_" + "ButtonExit");
            ButtonExit.TextureButtonLight = Content.Load<Texture2D>(locale + "_" + "ButtonExitPressed");
        }

        // обновление игрового процесса (выполняется в каждый момент времени)
        protected override void Update(GameTime gameTime)
        {
            base.Update(gameTime); // обновить счетчик игрового времени
            // обработка нажатия кнопки "назад"
            if (GameProcess.IsGame)
            {
                if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) GameProcess.IsPause = true;
            }
            if (GameProcess.IsPause)
            {
                if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) GameProcess.IsPause = false;
            }
        }

        // отрисовка визуальных объектов (выполняется в каждый конкретный момент времени)
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.AliceBlue); // заполнить фон
            SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); // установить последовательный порядок отрисовки объектов

            if (SplashShown)
            {
                if (SplashHide)
                {
                    LoadData(Language); // загрузка данных (текстур, звуков, шрифта)
                    SplashShown = false;
                    SplashHide = true;
                }
                else
                {
                    SplashHide = true;
                }
                SpriteBatch.Draw(Splash, new Vector2(AbsoluteX(0), AbsoluteY(0)),
                                 new Rectangle(0, 0, Splash.Width, Splash.Height),
                                 Color.White, 0,
                                 new Vector2(0, 0), Dx, SpriteEffects.None, 0);
            }
            else
            {
                if (GameProcess.IsGame)
                {
                    // если происходит игровой процесс - отрисовка игрового процесса
                    if (!GameProcess.IsPause)
                    {
                        // если пауза выключена
                        SpriteBatch.Draw(TextureBackground, new Vector2(AbsoluteX(0), AbsoluteY(0)),
                                         new Rectangle(0, 0, TextureBackground.Width, TextureBackground.Height),
                                         Color.White,
                                         0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0); // отрисовка фона
                        foreach (var enemy in GameProcess.Enemies)
                        {
                            // отрисовка целей
                            SpriteBatch.Draw(TextureTarget,
                                             new Vector2(AbsoluteX(enemy.Screenpos.X), AbsoluteY(enemy.Screenpos.Y)),
                                             new Rectangle(0, 0, TextureTarget.Width, TextureTarget.Height), Color.White,
                                             0, new Vector2(TextureTarget.Width/2f, TextureTarget.Height/2f), 1*Dx,
                                             SpriteEffects.None, 0);

                        }
                        // отрисовка игрока
                        if (Player.Direction == 2)
                            SpriteBatch.Draw(TexturePlayerBack, new Vector2(AbsoluteX(Player.X), AbsoluteY(Player.Y)),
                                             new Rectangle(0, 0, TexturePlayerBack.Width, TexturePlayerBack.Height),
                                             Color.White,
                                             Player.Angle,
                                             new Vector2(TexturePlayerBack.Width/2f, TexturePlayerBack.Height/2f), 1*Dx,
                                             SpriteEffects.None, 0);
                            // отраженный вариант (движени влево)
                        else
                            SpriteBatch.Draw(TexturePlayer, new Vector2(AbsoluteX(Player.X), AbsoluteY(Player.Y)),
                                             new Rectangle(0, 0, TexturePlayer.Width, TexturePlayer.Height), Color.White,
                                             Player.Angle,
                                             new Vector2(TexturePlayerBack.Width/2f, TexturePlayerBack.Height/2f), 1*Dx,
                                             SpriteEffects.None, 0);
                        // обычный вариант (вправо/вверх/вниз)
                        foreach (var badenemy in GameProcess.BadEnemies)
                        {
                            SpriteBatch.Draw(TextureTargetBad,
                                             new Vector2(AbsoluteX(badenemy.Screenpos.X),
                                                         AbsoluteY(badenemy.Screenpos.Y)),
                                             new Rectangle(0, 0, TextureTargetBad.Width, TextureTargetBad.Height),
                                             Color.White,
                                             badenemy.Rotation, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                        }
                        // отрисовка колючек по краям экрана
                        SpriteBatch.Draw(TextureBorder, new Vector2(AbsoluteX(0), AbsoluteY(0)),
                                         new Rectangle(0, 0, TextureBorder.Width, TextureBorder.Height), Color.White,
                                         0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                        // отрисовка числа очков
                        SpriteBatch.DrawString(Font,
                                               strRecord + GameProcess.MaxScore.ToString(CultureInfo.InvariantCulture),
                                               new Vector2(20, 60),
                                               Color.White, 0, new Vector2(0, 0), 0.8f*Dx, SpriteEffects.None, 0);
                        SpriteBatch.DrawString(Font,
                                               strScoreAmount + GameProcess.Score.ToString(CultureInfo.InvariantCulture),
                                               new Vector2(20, 10),
                                               GameProcess.Score <= GameProcess.MaxScore
                                                   ? Color.White
                                                   : new Color(66, 160, 208), 0,
                                               new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                        // отрисовка жизней
                        SpriteBatch.Draw(TextureCross, new Vector2(AbsoluteX(690), AbsoluteY(15)),
                                         new Rectangle(0, 0, TextureCross.Width, TextureCross.Height),
                                         Player.Lives >= 1 ? Color.White : Color.Red,
                                         0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                        SpriteBatch.Draw(TextureCross, new Vector2(AbsoluteX(743), AbsoluteY(15)),
                                         new Rectangle(0, 0, TextureCross.Width, TextureCross.Height),
                                         Player.Lives >= 2 ? Color.White : Color.Red,
                                         0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                        SpriteBatch.Draw(TextureCross, new Vector2(AbsoluteX(793), AbsoluteY(15)),
                                         new Rectangle(0, 0, TextureCross.Width, TextureCross.Height),
                                         Player.Lives >= 3 ? Color.White : Color.Red,
                                         0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                        SpriteBatch.Draw(TextureCross, new Vector2(AbsoluteX(845), AbsoluteY(15)),
                                         new Rectangle(0, 0, TextureCross.Width, TextureCross.Height),
                                         Player.Lives >= 4 ? Color.White : Color.Red,
                                         0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                        SpriteBatch.Draw(TextureCross, new Vector2(AbsoluteX(896), AbsoluteY(15)),
                                         new Rectangle(0, 0, TextureCross.Width, TextureCross.Height),
                                         Player.Lives >= 5 ? Color.White : Color.Red,
                                         0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);

                        // обработка действий целей ("false" означает, что ускорение выключено)
                        foreach (var enemy in GameProcess.Enemies)
                            enemy.Process(false, gameTime);
                        // обработка действий шестеренок ("true" означает, что включено ускорение)
                        foreach (var badenemy in GameProcess.BadEnemies)
                            badenemy.Process(true, gameTime);
                        Player.Process(gameTime); // обработка движения игрока
                        Player.WorkWithTarget(); // обработка работы с целями
                        // обработка управления движением
                        // вверх
                        ButtonUp.Process(SpriteBatch);
                        ButtonUp.Update(90, 205);
                        if (ButtonUp.IsEnabled)
                        {
                            ButtonUp.Reset();
                            Player.Direction = 3;
                        }
                        // вниз
                        ButtonDown.Process(SpriteBatch);
                        ButtonDown.Update(90, 430);
                        if (ButtonDown.IsEnabled)
                        {
                            ButtonDown.Reset();
                            Player.Direction = 4;
                        }
                        // влево
                        ButtonLeft.Process(SpriteBatch);
                        ButtonLeft.Update(18, 312);
                        if (ButtonLeft.IsEnabled)
                        {
                            ButtonLeft.Reset();
                            Player.Direction = 2;
                        }
                        // вправо
                        ButtonRight.Process(SpriteBatch);
                        ButtonRight.Update(175, 312);
                        if (ButtonRight.IsEnabled)
                        {
                            Player.Direction = 1;
                            ButtonRight.Reset();
                        }
                        // пауза
                        ButtonPause.Process(SpriteBatch);
                        ButtonPause.Update(830, 484);
                        if (ButtonPause.IsEnabled)
                        {
                            ButtonPause.Reset();
                            GameProcess.IsPause = true;
                        }
                    }
                    else
                    {
                        // если пауза включена
                        SpriteBatch.Draw(TextureBackgroundLoad, new Vector2(AbsoluteX(0), AbsoluteY(0)),
                                         new Rectangle(0, 0, TextureBackgroundLoad.Width, TextureBackgroundLoad.Height),
                                         Color.White,
                                         0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);

                        SpriteBatch.DrawString(Font,
                                               "Пауза",
                                               new Vector2(AbsoluteX(420), AbsoluteY(1175)),
                                               Color.White, 0, new Vector2(0, 0), 1.5f*Dx, SpriteEffects.None, 0);
                        // продолжить
                        ButtonResumeGame.Process(SpriteBatch);
                        ButtonResumeGame.Update(328, 351);
                        if (ButtonResumeGame.IsEnabled)
                        {
                            ButtonResumeGame.Reset();
                            GameProcess.IsPause = false;
                        }
                        // начать новую игру
                        ButtonStartNewGame.Process(SpriteBatch);
                        ButtonStartNewGame.Update(320, 420);
                        if (ButtonStartNewGame.IsEnabled)
                        {
                            ButtonStartNewGame.Reset();
                            GameProcess = new GameProcess();
                            GameProcess.IsGame = true;
                            Player = new Player(GameProcess);
                        }
                        // выход
                        ButtonExit.Process(SpriteBatch);
                        ButtonExit.Update(320, 483);
                        if (ButtonExit.IsEnabled)
                        {
                            ButtonExit.Reset();
                            Exit();
                        }
                        // посетить сайт
                        ButtonVisitSite.Process(SpriteBatch);
                        ButtonVisitSite.Update(741, 475);
                        if (ButtonVisitSite.IsEnabled)
                        {
                            ButtonVisitSite.Reset();
                            var uri = Android.Net.Uri.Parse("http://www.dageron.com/?cat=146");
                            var intent = new Intent(Intent.ActionView, uri);
                            Activity.StartActivity(intent);
                        }
                    }
                }
                else
                {
                    if (GameProcess.IsLose)
                    {
                        // отрисовка меню поражения
                        SpriteBatch.Draw(TextureBackgroundLose, new Vector2(AbsoluteX(0), AbsoluteY(0)),
                                         new Rectangle(0, 0, TextureBackgroundLose.Width, TextureBackgroundLose.Height),
                                         Color.White,
                                         0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                        SpriteBatch.DrawString(Font,
                                               strRecordString +
                                               GameProcess.MaxScore.ToString(CultureInfo.InvariantCulture) +
                                               strRecordNotReached,
                                               new Vector2(AbsoluteX(350), AbsoluteY(252)),
                                               Color.White, 0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                        if (Player.Lives == 5)
                            SpriteBatch.DrawString(Font, strPacmanInjured,
                                                   new Vector2(AbsoluteX(258), AbsoluteY(278)),
                                                   Color.White, 0, new Vector2(0, 0), 1.2f*Dx, SpriteEffects.None, 0);
                        // сыграть заново
                        ButtonReplay.Process(SpriteBatch);
                        ButtonReplay.Update(330, 355);
                        if (ButtonReplay.IsEnabled)
                        {
                            ButtonReplay.Reset();
                            GameProcess = new GameProcess();
                            GameProcess.IsGame = true;
                            Player = new Player(GameProcess);
                        }
                    }
                    else
                    {
                        if (GameProcess.IsWin)
                        {
                            // отрисовка меню победы
                            SpriteBatch.Draw(TextureBackgroundWin, new Vector2(AbsoluteX(0), AbsoluteY(0)),
                                             new Rectangle(0, 0, TextureBackgroundWin.Width, TextureBackgroundWin.Height),
                                             Color.White,
                                             0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                            SpriteBatch.DrawString(Font,
                                                   strNewRecord +
                                                   GameProcess.Score.ToString(CultureInfo.InvariantCulture) + "!",
                                                   new Vector2(AbsoluteX(350), AbsoluteY(252)),
                                                   Color.White, 0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                            // начать новую игру
                            ButtonStartNewGame.Process(SpriteBatch);
                            ButtonStartNewGame.Update(320, 320);
                            if (ButtonStartNewGame.IsEnabled)
                            {
                                ButtonStartNewGame.Reset();
                                GameProcess = new GameProcess();
                                GameProcess.IsGame = true;
                                Player = new Player(GameProcess);
                            }
                            // выйти
                            ButtonExit.Process(SpriteBatch);
                            ButtonExit.Update(332, 395);
                            if (ButtonExit.IsEnabled)
                            {
                                ButtonExit.Reset();
                                Exit();
                            }
                            // посетить сайт
                            ButtonVisitSite.Process(SpriteBatch);
                            ButtonVisitSite.Update(380, 180);
                            if (ButtonVisitSite.IsEnabled)
                            {
                                ButtonVisitSite.Reset();
                                var uri = Android.Net.Uri.Parse("http://www.dageron.com/?cat=146");
                                var intent = new Intent(Intent.ActionView, uri);
                                Activity.StartActivity(intent);
                            }
                        }
                        else
                        {
                            // отрисовать стартовый экран игры
                            SpriteBatch.Draw(TextureBackgroundLoad, new Vector2(AbsoluteX(0), AbsoluteY(0)),
                                             new Rectangle(0, 0, TextureBackgroundLoad.Width,
                                                           TextureBackgroundLoad.Height),
                                             Color.White,
                                             0, new Vector2(0, 0), 1*Dx, SpriteEffects.None, 0);
                            // начать новую игру
                            ButtonNewGame.Process(SpriteBatch);
                            ButtonNewGame.Update(330, 455);
                            if (ButtonNewGame.IsEnabled)
                            {
                                ButtonNewGame.Reset();
                                GameProcess = new GameProcess();
                                GameProcess.IsGame = true;
                                Player = new Player(GameProcess);
                            }
                            // посетить сайт
                            ButtonVisitSite.Process(SpriteBatch);
                            ButtonVisitSite.Update(741, 475);
                            if (ButtonVisitSite.IsEnabled)
                            {
                                ButtonVisitSite.Reset();
                                var uri = Android.Net.Uri.Parse("http://www.dageron.com/?cat=146");
                                var intent = new Intent(Intent.ActionView, uri);
                                Activity.StartActivity(intent);
                            }
                        }
                    }
                }
                // отрисовать рамки
                DrawRectangle(
                    new Rectangle(-100, -100, CurrentWidth + 100 + 100, 100 + (int) deltaY),
                    Color.Black);
                DrawRectangle(
                    new Rectangle(-100, CurrentHeigth - (int) deltaY, CurrentWidth + 100 + 100,
                                  (int) deltaY + (int) deltaY_1 + 100), Color.Black);
            }
            SpriteBatch.End(); // прервать отрисовку на данном этапе
            base.Draw(gameTime); // обновить счетчик игрового времени
        }
    }
}

Остановимся немного подробнее на методе Update — он выполняется параллельно Draw и удобен для обработки управления, в частности, кнопкой «назад» на самом телефоне или планшете (любое использование GamePad в пределах Draw приведет к зависанию).

        // обновление игрового процесса (выполняется в каждый момент времени)
        protected override void Update(GameTime gameTime)
        {
            base.Update(gameTime); // обновить счетчик игрового времени
            // обработка нажатия кнопки "назад"
            if (GameProcess.IsGame)
            {
                if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) GameProcess.IsPause = true;
            }
            if (GameProcess.IsPause)
            {
                if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) GameProcess.IsPause = false;
            }
        }

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

Важный момент, на котором тоже следует остановиться, это сохранение рекордов. Для этого я написал класс Game.Score.cs, который также использую во всех своих проектах с минимальными изменениями (на MonoGame подобные приемы очень удобны).

Game.Score.cs

using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using System.Collections.Generic;

namespace pacmangame
{
	[Serializable]
	public class ClassScoreManager
	{
		// класс "счет"
		[Serializable]
		public class ScoreItem
		{
			public int Value = 0;
		}

		public ScoreItem Score;

		// прочитать счет из файла
		public ClassScoreManager ReadScores()
		{
			try
			{
				var sdCardPath = Android.OS.Environment.ExternalStorageDirectory.AbsolutePath;
				var filePath = System.IO.Path.Combine(sdCardPath+"/Application/Dageron Studio", "dageron_angry_pacman.xml");
				FileStream fStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
				var myBinaryFormatter = new BinaryFormatter();
				var mc = (ClassScoreManager) myBinaryFormatter.Deserialize(fStream);
				fStream.Close();
				return mc;
			}
			catch (Exception e)
			{
				Score = new ScoreItem ();
				return this;
			}
		}

		// записать счет в файл
		public void WriteScores()
		{
			var sdCardPath = Android.OS.Environment.ExternalStorageDirectory.AbsolutePath;
			if (!Directory.Exists(sdCardPath +"/Application")) Directory.CreateDirectory (sdCardPath +"/Application");
			if (!Directory.Exists(sdCardPath +"/Application/Dageron Studio")) Directory.CreateDirectory (sdCardPath +"/Application/Dageron Studio");
			var filePath = System.IO.Path.Combine(sdCardPath+"/Application/Dageron Studio", "dageron_angry_pacman.xml");
			FileStream fStream = new FileStream(filePath, FileMode.Create, FileAccess.Write);
			var myBinaryFormatter = new BinaryFormatter();
			myBinaryFormatter.Serialize(fStream, this);
			fStream.Close();
		}
	}
}

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

Пишем Android игру на Xamarin+MonoGame (C#)

Заключение

Скриншоты получившейся игры тут и тут. В Google Play пользователям игра нравится, во всяком случае пока :)
Полные исходники смотрите на GitHub

В принципе MonoGame прост и удобен для создания двухмерных игр, недостатком является отсутствие автоматизации многих типичных задач (даже тут с ними пришлось столкнуться: подгрузка контента, кнопки, сохранение рекордов). Если вы раньше работали с XNA Game Studio, многие вещи покажутся знакомыми, простыми и понятными, у вас есть все возможности для кроссплатформенной реализации своих проектов. Если нет (и, в особенности, если хотите делать трехмерные игры) — рассмотрите возможности других движков, к примеру, Unity.

Надеюсь, ничего не забыл :)
Если будут вопросы — спрашивайте.

Автор: Dageron

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js