Разработка (футбольных) игр с помощью MonoGame
Разрабатывать игры хотят все, и это неудивительно: они популярны и хорошо продаются. Кто не мечтает прославиться и разбогатеть, сделав очередные Angry Birds или Halo? В реальности, однако, разработка игр — одна из самых сложных задач в программировании, ведь в игре нужно подобрать такое сочетание графики, звука и геймплея, чтобы она захватила пользователя.Чтобы облегчить жизнь разработчикам игр, создаются разнообразные фреймоворки, не только для С и С++, но и для С# и даже JavaScript. Одним из таких фреймворков является Microsoft XNA, использующий технологию Microsoft DirectX и позволяющий создавать игры для Xbox 360, Windows, and Windows Phone. Microsoft XNA сейчас уже более не развивается, однако в то же время сообщество Open Source предложило другой вариант — MonoGame. Познакомимся с этим фреймворком поближе на примере простой футбольной (к чему бы это?) игры.Что такое MonoGame? MonoGame — это open source реализация XNA API не только для Windows, но и для Mac OS X, Apple iOS, Google Android, Linux и Windows Phone. Это означает, что вы можете создавать игры сразу под все эти платформы с минимальными изменениями. Идеально для тех, кто строит планы захвата мира! Вам даже не требуется Windows для разработки с MonoGame. Вы можете использовать MonoDevelop (open source кросс-платформенный IDE для языков Microsoft .NET) или кросс-платформенный IDE Xamarin Studio для работы на Linux и Mac.Если вы являетесь разработчиком Microsoft .NET и ежедневно используете Microsoft Visual Studio, MonoGame можно установить и туда. На момент написания статьи последней стабильной версией MonoGame была 3.2, она устанавливается в Visual Studio 2012 и 2013.Создаем первую игру Чтобы создать первую игру, в меню шаблонов выберем MonoGame Windows Project. Visual Studio создаст новый проект со всеми необходимыми файлами и ссылками. Если вы запустите проект, то получите что-то такое: Скучновато, не правда ли? Ничего, это только начало. Вы можете начинать разработку своей игры в этом проекте, но есть один нюанс. Вы не сможете добавлять какие-либо объекты (рисунки, спрайты, шрифты и т.д.) без преобразования их в совместимый с MonoGame формат. Для этого вам понадобится что-то из следующего: Установить XNA Game Studio 4.0 Установить Windows Phone 8 SDK Использовать внешнюю программу вроде XNA content compiler Итак, в Program.cs у вас находится функция Main. Она инициализирует и запускает игру. static void Main () { using (var game = new Game1()) game.Run (); } Game1.cs — ядро игры. У вас есть два метода, которые вызываются 60 раз в секунду: Update и Draw. При Update вы пересчитываете данные для всех элементов игры; Draw, соответственно, подразумевает отрисовку этих элементов. Заметьте, что времени на итерацию цикла дается совсем немного — всего 16.7 мс. Если времени на выполнение цикла не хватает, программа пропустит несколько методов Draw, что, естественно, будет заметно на картинке.Для примера мы создадим футбольную игру «забей пенальти». Управляться удар будет нашим прикосновением, а компьютерный «вратарь» будет стараться поймать мяч. Компьютер выбирает случайные местоположение и скорость «вратаря». Очки считаются привычным нам способом.Добавляем контент в игру Первый шаг в создании игры — добавление контента. Начнем с фонового рисунка поля и рисунка мяча. Создадим два PNG рисунка: поля (внизу) и мяча (на КДПВ).
Чтобы использовать эти рисунки в игре, их нужно скомпилировать. Если вы используете XNA Game Studio или Windows Phone 8 SDK, вам нужно создать XNA контент проект. Добавьте рисунки в этот проект и соберите его. Затем зайдите в каталог проекта с копируйте получившиеся .xnb файлы в ваш проект. XNA Content Compiler не требует нового проекта, объекты в нем можно компилировать по мере необходимости.Когда .xnb файлы готовы, добавьте их в папку Content вашей игры. Создадим два поля, в которых будем хранить текстуры мяча и поля:
private Texture2D _backgroundTexture; private Texture2D _ballTexture; Эти поля загружаются в методе LoadContent:
protected override void LoadContent () { // Create a new SpriteBatch, which can be used to draw textures. _spriteBatch = new SpriteBatch (GraphicsDevice);
// TODO: use this.Content to load your game content here
_backgroundTexture = Content.Load
Теперь нарисуем текстуры в методе Draw: protected override void Draw (GameTime gameTime) { GraphicsDevice.Clear (Color.Green);
// Set the position for the background var screenWidth = Window.ClientBounds.Width; var screenHeight = Window.ClientBounds.Height; var rectangle = new Rectangle (0, 0, screenWidth, screenHeight); // Begin a sprite batch _spriteBatch.Begin (); // Draw the background _spriteBatch.Draw (_backgroundTexture, rectangle, Color.White); // Draw the ball var initialBallPositionX = screenWidth / 2; var ínitialBallPositionY = (int)(screenHeight * 0.8); var ballDimension = (screenWidth > screenHeight) ? (int)(screenWidth * 0.02) : (int)(screenHeight * 0.035); var ballRectangle = new Rectangle (initialBallPositionX, ínitialBallPositionY, ballDimension, ballDimension); _spriteBatch.Draw (_ballTexture, ballRectangle, Color.White); // End the sprite batch _spriteBatch.End (); base.Draw (gameTime); } Этот метод заливает экран зеленым, а затем рисует фон и мяч на точке пенальти. Первый метод spriteBatch Draw рисует фон, подогнанный к размеру окна, второй метод рисует мяч на точке пенальти. Движения здесь пока нет — надо его добавить.Движение мячаЧтобы мяч двигался, нужно пересчитывать его местоположение в каждой итерации цикла и рисовать его на новом месте. Высчитаем новую позицию в методе Update: protected override void Update (GameTime gameTime) { if (GamePad.GetState (PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState ().IsKeyDown (Keys.Escape)) Exit ();
// TODO: Add your update logic here _ballPosition -= 3; _ballRectangle.Y = _ballPosition; base.Update (gameTime);
} Позиция мяча обновляется в каждом цикле путем вычитания 3 пикселей. Переменные _screenWidth, _screenHeight, _backgroundRectangle, _ballRectangle и _ballPosition инициализируются в методе ResetWindowSize: private void ResetWindowSize () { _screenWidth = Window.ClientBounds.Width; _screenHeight = Window.ClientBounds.Height; _backgroundRectangle = new Rectangle (0, 0, _screenWidth, _screenHeight); _initialBallPosition = new Vector2(_screenWidth / 2.0f, _screenHeight * 0.8f); var ballDimension = (_screenWidth > _screenHeight) ? (int)(_screenWidth * 0.02) : (int)(_screenHeight * 0.035); _ballPosition = (int)_initialBallPosition.Y; _ballRectangle = new Rectangle ((int)_initialBallPosition.X, (int)_initialBallPosition.Y, ballDimension, ballDimension); } Этот метод сбрасывает все переменные, зависящие от размера окна. Он вызывается в методе Initialize. protected override void Initialize () { // TODO: Add your initialization logic here ResetWindowSize (); Window.ClientSizeChanged += (s, e) => ResetWindowSize (); base.Initialize (); } Этот метод вызывается в двух местах: в начале и каждый раз, когда изменяется размер окна. Если вы запустите программу, вы заметите, что мяч движется прямо, но не останавливается с окончанием поля. Нужно переместить мяч, когда он залетает в ворота с помощью следующего кода: protected override void Update (GameTime gameTime) { if (GamePad.GetState (PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState ().IsKeyDown (Keys.Escape)) Exit ();
// TODO: Add your update logic here _ballPosition -= 3; if (_ballPosition < _goalLinePosition) _ballPosition = (int)_initialBallPosition.Y;
_ballRectangle.Y = _ballPosition; base.Update (gameTime);
} Переменная _goalLinePosition также инициализируется в методе ResetWindowSize. _goalLinePosition = _screenHeight * 0.05; В методе Draw нужно еще убрать все вычисления: protected override void Draw (GameTime gameTime) { GraphicsDevice.Clear (Color.Green);
var rectangle = new Rectangle (0, 0, _screenWidth, _screenHeight); // Begin a sprite batch _spriteBatch.Begin (); // Draw the background _spriteBatch.Draw (_backgroundTexture, rectangle, Color.White); // Draw the ball _spriteBatch.Draw (_ballTexture, _ballRectangle, Color.White); // End the sprite batch _spriteBatch.End (); base.Draw (gameTime); } Движение мяча перпендикулярно линии ворот. Если вы хотите перемещать мяч под углом, создайте переменную _ballPositionX и увеличивайте ее (для движения вправо) или уменьшайте (для движения влево). Еще лучший вариант — использовать Vector2 для положения мяча: protected override void Update (GameTime gameTime) { if (GamePad.GetState (PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState ().IsKeyDown (Keys.Escape)) Exit ();
// TODO: Add your update logic here _ballPosition.X -= 0.5f; _ballPosition.Y -= 3; if (_ballPosition.Y < _goalLinePosition) _ballPosition = new Vector2(_initialBallPosition.X,_initialBallPosition.Y); _ballRectangle.X = (int)_ballPosition.X; _ballRectangle.Y = (int)_ballPosition.Y; base.Update(gameTime);
} Если вы запустите программу теперь, то увидите, что мяч летит под углом. Следующая задача — приделать управление пальцем.Реализуем управление В нашей игре управление осуществляется пальцем. Движение пальца задает направление и силу удара.В MonoGame сенсорные данные получают с помощью класса TouchScreen. Вы можете использовать сырые данные или Gestures API. Сырые данные обеспечивают большую гибкость, поскольку вы получаете доступ ко всей информации, Gestures API трансформирует сырые данные в жесты, и вы можете отфильтровать только те, которые вам требуются.В нашей игре нам нужен только щелчок и, поскольку Gestures API поддерживает такое движение, мы воспользуемся ей. Первым делом, обзначим, каким жестом мы будем пользоваться: TouchPanel.EnabledGestures = GestureType.Flick | GestureType.FreeDrag; Будут обрабатываться только щелчки и перетаскивания. Далее в методе Update обработаем жесты: if (TouchPanel.IsGestureAvailable) { // Read the next gesture GestureSample gesture = TouchPanel.ReadGesture (); if (gesture.GestureType == GestureType.Flick) { … } } Включим щелчок в методе Initialize: protected override void Initialize () { // TODO: Add your initialization logic here ResetWindowSize (); Window.ClientSizeChanged += (s, e) => ResetWindowSize (); TouchPanel.EnabledGestures = GestureType.Flick; base.Initialize (); } До сих пор мяч катился все время, пока игра выполнялась. Используйте переменную _isBallMoving, чтобы сообщить игре, когда мяч движется. В методе Update, когда будет обнаружен щелчок, установите _isBallMoving равным True, и движение начнется. Когда мяч перечет линию ворот, установите _isBallMoving в False и верните мяч в исходное положение: protected override void Update (GameTime gameTime) { if (GamePad.GetState (PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState ().IsKeyDown (Keys.Escape)) Exit ();
// TODO: Add your update logic here if (!_isBallMoving && TouchPanel.IsGestureAvailable) { // Read the next gesture GestureSample gesture = TouchPanel.ReadGesture (); if (gesture.GestureType == GestureType.Flick) { _isBallMoving = true; _ballVelocity = gesture.Delta * (float)TargetElapsedTime.TotalSeconds / 5.0f; } } if (_isBallMoving) { _ballPosition += _ballVelocity; // reached goal line if (_ballPosition.Y < _goalLinePosition) { _ballPosition = new Vector2(_initialBallPosition.X, _initialBallPosition.Y); _isBallMoving = false; while (TouchPanel.IsGestureAvailable) TouchPanel.ReadGesture(); } _ballRectangle.X = (int) _ballPosition.X; _ballRectangle.Y = (int) _ballPosition.Y; } base.Update(gameTime);
} Скорость мяча теперь уже не константа, программа использует переменную _ballVelocity для установки скорости по осям x и y. Gesture.Delta возвращает изменение движения со времени последнего обновления. Чтобы подсчитать скорость щелчка, умножьте этот вектор на TargetElapsedTime.Если мяч движется, вектор _ballPosition изменяется исходя из скорости (в пикселях за фрейм) до тех пор, пока мяч не достигнет линии ворот. Следующий код останавливает мяч и удаляет все жесты из входной очереди: _isBallMoving = false; while (TouchPanel.IsGestureAvailable) TouchPanel.ReadGesture (); Теперь если вы запустите мяч, он полетит по направлению щелчка и с его скоростью. Однако тут есть одна проблема: программа не смотрит, где на экране произошел щелчок. Вы можете щелкнуть где угодно, и мяч начнет движение. Решение — использовать сырые данные, получить точку прикосновения и посмотреть, находится ли она около мяча. Если да, жест устанавливает переменную _isBallHit: TouchCollection touches = TouchPanel.GetState ();
if (touches.Count > 0 && touches[0].State == TouchLocationState.Pressed) { var touchPoint = new Point ((int)touches[0].Position.X, (int)touches[0].Position.Y); var hitRectangle = new Rectangle ((int)_ballPositionX, (int)_ballPositionY, _ballTexture.Width, _ballTexture.Height); hitRectangle.Inflate (20,20); _isBallHit = hitRectangle.Contains (touchPoint); } Тогда движение начинается, только если _isBallHit равна True: if (TouchPanel.IsGestureAvailable && _isBallHit) Имеется еще одна проблема. Если вы ударите мяч слишком медленно или не в ту сторону, игра закончится, так как мяч не пересечет линию ворот и не вернется в исходную позицию. Необходимо установить предельное время движения мяча. Когда таймаут достигнут, игра начинается снова: if (gesture.GestureType == GestureType.Flick) { _isBallMoving = true; _isBallHit = false; _startMovement = gameTime.TotalGameTime; _ballVelocity = gesture.Delta*(float) TargetElapsedTime.TotalSeconds/5.0f; }
…
var timeInMovement = (gameTime.TotalGameTime — _startMovement).TotalSeconds; // reached goal line or timeout if (_ballPosition.Y <' _goalLinePosition || timeInMovement > 5.0) { _ballPosition = new Vector2(_initialBallPosition.X, _initialBallPosition.Y); _isBallMoving = false; _isBallHit = false; while (TouchPanel.IsGestureAvailable) TouchPanel.ReadGesture (); } Добавление вратаря Наша игра работает — сделаем теперь ее посложнее, добавив вратаря, который будет двигаться после того, как мы ударим мяч. Вратарь представляет из себя рисунок в формате PNG, предварительно его откомпилируем.Вратарь загружается в методе LoadContent: protected override void LoadContent () { // Create a new SpriteBatch, which can be used to draw textures. _spriteBatch = new SpriteBatch (GraphicsDevice);
// TODO: use this.Content to load your game content here
_backgroundTexture = Content.Load
Отрисуем его в методе Draw protected override void Draw (GameTime gameTime) {
GraphicsDevice.Clear (Color.Green); // Begin a sprite batch _spriteBatch.Begin (); // Draw the background _spriteBatch.Draw (_backgroundTexture, _backgroundRectangle, Color.White); // Draw the ball _spriteBatch.Draw (_ballTexture, _ballRectangle, Color.White); // Draw the goalkeeper _spriteBatch.Draw (_goalkeeperTexture, _goalkeeperRectangle, Color.White); // End the sprite batch _spriteBatch.End (); base.Draw (gameTime); } _goalkeeperRectangle — прямоугольник вратаря в окне. Он изменяется в методе Update: protected override void Update (GameTime gameTime) { …
_ballRectangle.X = (int) _ballPosition.X;
_ballRectangle.Y = (int) _ballPosition.Y;
_goalkeeperRectangle = new Rectangle (_goalkeeperPositionX, _goalkeeperPositionY,
_goalKeeperWidth, _goalKeeperHeight);
base.Update (gameTime);
}
Переменные _goalkeeperPositionY, _goalKeeperWidth и _goalKeeperHeight fields обновляются в методе ResetWindowSize:
private void ResetWindowSize ()
{
…
_goalkeeperPositionY = (int) (_screenHeight*0.12);
_goalKeeperWidth = (int)(_screenWidth * 0.05);
_goalKeeperHeight = (int)(_screenWidth * 0.005);
}
Первоначальное положение вратаря — по центру окна перед линией ворот:
_goalkeeperPositionX = (_screenWidth — _goalKeeperWidth)/2;
Вратарь начинает движение вместе с мячом. Он совершает гармонические колебания из стороны в сторону: X = A * sin (at + δ), Где А — амплитуда колебаний (ширина ворот), t — период колебаний, а δ — случайная величина, чтобы игрок не мог предугадать движение вратаря.Коэффициенты вычисляются в момент удара по мячу:
if (gesture.GestureType == GestureType.Flick)
{
_isBallMoving = true;
_isBallHit = false;
_startMovement = gameTime.TotalGameTime;
_ballVelocity = gesture.Delta * (float)TargetElapsedTime.TotalSeconds / 5.0f;
var rnd = new Random ();
_aCoef = rnd.NextDouble () * 0.005;
_deltaCoef = rnd.NextDouble () * Math.PI / 2;
}
Коэффициент, а — скорость вратаря, число между 0 и 0.005, представляющее скорость между 0 и 0.3 пикселя в секунду. Коэффициент дельта — число между 0 и π/2. Когда двигается мяч, позиция вратаря обновляется:
if (_isBallMoving)
{
_ballPositionX += _ballVelocity.X;
_ballPositionY += _ballVelocity.Y;
_goalkeeperPositionX = (int)((_screenWidth * 0.11) *
Math.Sin (_aCoef * gameTime.TotalGameTime.TotalMilliseconds +
_deltaCoef) + (_screenWidth * 0.75) / 2.0 + _screenWidth * 0.11);
…
}
Амплитуда движения — _screenWidth * 0.11 (ширина ворот). Добавьте (_screenWidth * 0.75) / 2.0 + _screenWidth * 0.11 к результату, чтобы вратарь двигался перед воротами.Проверка, пойман ли мяч и добавление счета
Чтобы проверить, пойман мяч или нет, нужно посмотреть, пересекаются ли прямоугольники вратаря и мяча. Сделаем это в методе Update после вычисления позиций:
_ballRectangle.X = (int)_ballPosition.X;
_ballRectangle.Y = (int)_ballPosition.Y;
_goalkeeperRectangle = new Rectangle (_goalkeeperPositionX, _goalkeeperPositionY,
_goalKeeperWidth, _goalKeeperHeight);
if (_goalkeeperRectangle.Intersects (_ballRectangle))
{
ResetGame ();
}
ResetGame возвращает игру в исходное состояние:
private void ResetGame ()
{
_ballPosition = new Vector2(_initialBallPosition.X, _initialBallPosition.Y);
_goalkeeperPositionX = (_screenWidth — _goalKeeperWidth) / 2;
_isBallMoving = false;
_isBallHit = false;
while (TouchPanel.IsGestureAvailable)
TouchPanel.ReadGesture ();
}
Теперь нужно проверить, попал ли мяч в ворота. Сделаем это, когда мяч пересекает линию:
var isTimeout = timeInMovement > 5.0;
if (_ballPosition.Y < _goalLinePosition || isTimeout)
{
bool isGoal = !isTimeout &&
(_ballPosition.X > _screenWidth * 0.375) &&
(_ballPosition.X < _screenWidth * 0.623);
ResetGame();
}
Чтобы добавить ведение счета, в игру нужно добавить новый объект – шрифт, которым будут писаться цифры. Шрифт – это XML файл, описывающий шрифт: вид, размер, начертание и т.д. В игре мы будем использовать такой шрифт: