Привет всем начинающим геймдевелоперам и просто хорошим людям. Сегодня, я хочу познакомить вас с замечательным фреймворком XNA (набор инструментов с управляемой средой времени выполнения dotNet). Программировать мы будем на C#.Для того, чтобы познакомить вас с XNA ближе, я предлагаю написать простую «музыкальную» 2D игрушку. Остальное под катом.
Краткое описание на википедии
Microsoft XNA (англ. XNA's Not Acronymed) — набор инструментов с управляемой средой времени выполнения (.NET), созданный Microsoft, облегчающий разработку и управление компьютерными играми. XNA стремится освободить разработку игр от написания «повторяющегося шаблонного кода»
Что для этого нам будет нужно?
1) Свежий DirectX (например Июнь 2010)
2) Microsoft Visual C# 2010 EXPRESS (бесплатная лицензия)
3) Microsoft XNA Game Studio 4.0
Что предполагается разобрать и сделать на этом уроке?
- Подключить сборки XNA Framework
- Создать пустое приложение с закрашиванием фона
- Научиться подгружать контент
- Научиться работать со звуком
- Научиться работать с графикой
Какую игру мы будем реализовывать?
Механика игры проста до безумия. Смысл будет построен на музыке, в случае с этой игрой, будет использована композиция Исаака Шепарда — Leaves in the Wind. Нужно будет ловить мышкой «ноты», скорость и кол-во которых будут зависимы от текущей позиции в музыки, грубо говоря игровой «визуализатор». Для разнообразия существуют 5 тип нот: обычные, красные (враги), пурпурные (мощь), мигающие (превращает все в желтые), желтые (увеличивает скорость набора очков и размеры).
Собираем вещи пустой проект
Для начала ставим все необходимые компоненты по порядку, затем запускаем Microsoft Visual C# 2010 EXPRESS и создаем проект Windows Game (4.0) и называем его music_catch:

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

Проект music_catch — «логика» нашего приложения.
Game1.cs — главный класс приложения, унаследован он от Microsoft.Xna.Framework.Game
Program.cs — «точка входа» в приложение, он нам не интересен.
Проект music_catchContent — «контент» нашего приложения, туда мы будем складывать ресурсы.
Более подробно взглянем на Game1.cs
В нем можно выделить основные функции, такие как:
Game1() — конструктор класса.
Initialize() — инициализация приложения.
LoadContent() — загрузка контента.
UnloadContent() — выгрузка контента.
Update(GameTime gameTime) — обновление логики приложения (например физики, etc)
Draw(GameTime gameTime) — отрисовка игры. ВНИМАНИЕ, любые операции с рисованием нужно проводить тут и только тут.
Пустой проект собран, идем дальше, добавляем ресурсы в приложение, все нужные ресурсы «кидаем» в папку music_catch\music_catchContent. В нашем случае — пять PNG файлов и одно музыкальное сопровождение. Добавляем это все в проект:

Там же создаем шрифт, в теле SpriteFont1.spritefont указываем имя и размер:
<FontName>Segoe UI Mono</FontName> <Size>14</Size>

Создаем переменные для будущего контента:
private List<Texture2D> MelList; private Texture2D mouse; private Song song; private SpriteFont font;
И грузим его в LoadContent():
MelList = new List<Texture2D>(); for(int a = 1; a <= 5; a++) MelList.Add(Content.Load<Texture2D>("mel" + a)); mouse = Content.Load<Texture2D>("mouse"); song = Content.Load<Song>("Leaves_in_the_Wind"); font = Content.Load<SpriteFont>("SpriteFont1");
Кстати, подгружается контент следующим образом: вызывается Content.Load<>(«asset»);
В треугольных скобках указывается процессор контента, в нашем случае это Texture2D, Song, SpriteFont. Можно использовать свои процессоры, об этом я расскажу как-нибудь потом.
Контент подгружен, идем в конструктор Game1() и пишем:
graphics = new GraphicsDeviceManager(this); graphics.PreferredBackBufferWidth = 800; // ширина приложения graphics.PreferredBackBufferHeight = 600; // высота приложения graphics.IsFullScreen = false; // флаг полноэкранного приложения graphics.ApplyChanges(); // применяем параметры Content.RootDirectory = "Content";
Приложение инициализировано.
Пишем «игровую логику»
Теперь нам нужно создать контроллер системы частиц и сами частицы (ноты), которые мы будем виртуозно ловить мышкой.
Создаем два класса: Catcher (сами частицы) и CatcherHolder (система частиц).
Листинг Catcher с комментариями:
using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.GamerServices; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Media; namespace MusicCatch { public class Catcher { public Texture2D Texture { get; set; } // Текстура частицы public Vector2 Position { get; set; } // Позиция частицы public Vector2 Velocity { get; set; } // Скорость частицы public float Angle { get; set; } // Угол поворота частицы public float AngularVelocity { get; set; } // Угловая скорость частицы public Color Color { get; set; } // Цвет частицы public float Size { get; set; } // Размер частицы public int TTL { get; set; } // Время жизни частицы private float RComponent; // Красный компонент RGB private float GComponent; // Зеленый компонент RGB private float BComponent; // Синий компонент RGB public int type; // Тип частицы private Random random; // Генератор случайных чисел public Catcher(Texture2D texture, Vector2 position, Vector2 velocity, float angle, float angularVelocity, int type, float size, int ttl) { // Установка переменных из конструктора Texture = texture; Position = position; Velocity = velocity; Angle = angle; AngularVelocity = angularVelocity; this.type = type; Size = size; TTL = ttl; SetType(type); // Установка цвета под определенный тип } public void ApplyImpulse(Vector2 vector) // Добавление импульса (используется бонусом) { Velocity += vector; } public void Update() // Обновление единичной частички { TTL--; Position += Velocity; Angle += AngularVelocity; if (type != -1) { Velocity = new Vector2(Velocity.X, Velocity.Y - .1f); Size = (10 + Velocity.Y) / 20; if(Size > 0.8f) Size = 0.8f; } if (type == 0) { GComponent -= 0.005f; BComponent += 0.005f; Color = new Color(RComponent, GComponent, BComponent); } else if (type == 4) { Color = new Color((float)(1f * random.NextDouble()), (float)(1f * random.NextDouble()), (float)(1f * random.NextDouble())); } } public void Draw(SpriteBatch spriteBatch) // Прорисовка частички { Rectangle sourceRectangle = new Rectangle(0, 0, Texture.Width, Texture.Height); Vector2 origin = new Vector2(Texture.Width / 2, Texture.Height / 2); spriteBatch.Draw(Texture, Position, sourceRectangle, Color, Angle, origin, Size, SpriteEffects.None, 0f); } public void SetType(int type) // Установка цвета частички { this.type = type; Color StartColor = new Color(1f, 1f, 1f); switch (type) { case 0: StartColor = new Color(0f, 1f, 0f); break; // Обычная case 1: StartColor = new Color(1f, 0f, 0f); break; // Красная case 2: StartColor = new Color(1f, 0f, 1f); break; // Пурпурная case 3: StartColor = new Color(1f, 1f, 0f); break; // Желтая case 4: random = new Random(); break; // Мигающая } RComponent = ((int)StartColor.R) / 255f; GComponent = ((int)StartColor.G) / 255f; BComponent = ((int)StartColor.B) / 255f; Color = new Color(RComponent, GComponent, BComponent); if (type == -1) { Color = new Color(1f, 1f, 1f, 0.1f); } } } }
Листинг CatcherHolder с комментариями:
using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.GamerServices; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Media; namespace MusicCatch { class CatcherHolder { private Random random; // Генератор случайных чисел public List<Catcher> particles; // Массив частичек (Catcher) private List<Texture2D> textures; // Массив текстур public List<float> accomulator { get; set; } // Массив float-значений, что такое и зачем нужен accomulator — объясню чуть позже. public CatcherHolder(List<Texture2D> textures) { this.textures = textures; this.particles = new List<Catcher>(); random = new Random(); accomulator = new List<float>(); // Инициализируем массив и записываем во все 128 ячеек — 1.0f for (int a = 0; a < 128; a++) { accomulator.Add(1.0f); } } // Генерация одной частички // Wave - волна, число от 0f до ширины экрана. private Catcher GenerateNewParticle(float Wave) { Texture2D texture = textures[random.Next(textures.Count)]; // Берем случайную текстуру из массива Vector2 position = new Vector2(Wave, 0); // Задаем позицию Vector2 velocity = new Vector2((float)(random.NextDouble() - 0.5), (float)(random.NextDouble() * 10)); // Случайное ускорение, 0.5f для X и 10f для Y float angle = 0; // Угол поворота = 0 float angularVelocity = 0.05f * (float)(random.NextDouble()*2 - 1 ); // Случайная скорость вращения Color color = new Color(0f, 1f, 0f); // Зеленый цвет (изменится цвет уже в самом Catcher) float size = (float)random.NextDouble()*.8f + .2f; // Случайный размер int ttl = 400; // Время жизни в 400 (400 актов рисования живет частица, т.е. 400 / 60 — 6 с лишним секунд. int type = 0; — изначальный тип 0 // Вероятность появления if (random.Next(10000) > 9900) // враг type = 1; else if (random.Next(10000) > 9950) // желтый type = 3; else if (random.Next(10000) > 9997) // пурпурный type = 2; else if (random.Next(10000) > 9998) // мигающий type = 4; return new Catcher(texture, position, velocity, angle, angularVelocity, type, size, ttl); // Создаем частичку и возвращаем её } // Генерация желтых частичек при касании с красной частичкой public void GenerateYellowExplossion(int x, int y, int radius) { Texture2D texture = textures[random.Next(textures.Count)]; Vector2 direction = Vector2.Zero; float angle = (float)Math.PI * 2.0f * (float)random.NextDouble(); float length = radius * 4f; direction.X = (float)Math.Cos(angle); direction.Y = -(float)Math.Sin(angle); Vector2 position = new Vector2(x, y) + direction * length; Vector2 velocity = direction * 4f; float angularVelocity = 0.05f * (float)(random.NextDouble() * 2 - 1); float size = (float)random.NextDouble() * .8f + .2f; int ttl = 400; int type = 3; particles.Add(new Catcher(texture, position, velocity, 0, angularVelocity, type, size, ttl)); } // "Музыкальный" импульс, создание частички public void Beat(float Wave) { particles.Add(GenerateNewParticle(Wave)); } public void Update() // Обновление всех частиц { for (int particle = 0; particle < particles.Count; particle++) { particles[particle].Update(); if (particles[particle].Size <= 0 || particles[particle].TTL <= 0) { // Если частичка дохлая или размер нуль или меньше, удаляем её particles.RemoveAt(particle); particle--; } } // Обновляем аккумулятор, если значения ячейки меньше 1f, то добавляем значение, указанное в статическом классе Constants — ACCUMULATE_SPEED, листинг Constanst - ниже. for (int a = 0; a < 128; a++) if (accomulator[a] < 1.0f) accomulator[a] += Constanst.ACCUMULATE_SPEED; } public void Draw(SpriteBatch spriteBatch) { // Прорисовываем все частички, важно указать BlendState.Additive, чтобы частички были более "мягкие". spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.Additive); for (int index = 0; index < particles.Count; index++) { particles[index].Draw(spriteBatch); } spriteBatch.End(); } } }
Листинг Constant.cs:
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace MusicCatch { public class Constanst { public const float BEAT_COST = .4f; // "Стоимость" частички, отнимается у аккумулятора при генерации новой частички public const float ACCUMULATE_SPEED = .01f; // Скорость аккумуляции public const float BEAT_REACTION = .5f; // Значение реакции на "бит" в музыке public const float ACCOMULATOR_REACTION = .5f; // Разрешает создавать новую частичку только тогда, когда значения больше реакции аккумулятора } }
Объясню, что за загадочный аккумулятор и зачем он нужен. Поговорим о «музыкальном» спектре.
Музыкальный сигнал – пища для аудиосистемы. Точнее – не так. Динамики музыку не слушают, ее восстанавливает наш мозг, получая сложный сигнал, содержащий множество частотных составляющих.
Дак вот, идея такая, слушать «частоты» каждый Update и записывать их в какой-нибудь, например, VisualizationData. Проще говоря, в массив из 128 элементов, которые изменяются от 0f до 1f.
Как этим можно воспользоваться?
Каждый Update: значения в массиве меняются в соответствии с музыкой, нам нужно прове��ить все 128 элементов, если значение элемента больше чем 0.6f, вызываем Beat-функцию и передаем ей Wave (индекс элемента массива, в котором произошло событие). Все бы хорошо, можно в Beat создавать частичку-ноту. Но представим, что у нас выполняется три Update'а подряд, в котором в одном и том же индексе — значение > 0.6f, как итог будет 100500 частичек за секунду. Чтобы таких вещей не происходило, можно использовать аккумулятор. Смысл его прост: при Beat'е у ячейки массива-аккумулятора соотвествующего индексу Wave отнимается константа BEAT_COST. Каждый Update ко всем элементам аккумулятора прибавляется ACCUMULATE_SPEED. Перед тем, как вызвать Beat проверяется выполняется ли условие — значение аккумулятора > ACCOMULATOR_REACTION, если да, то вызываем Beat. Это решает проблему.
Кстати, BEAT_REACTION — значение, после которых нужно проверять, стоит ли вызывать Beat.
Дальше приведу полный листинг GameLogic (Game1). Много кода, но постараюсь расписать в комментариях.
using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.GamerServices; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Media; namespace MusicCatch { public class Game1 : Microsoft.Xna.Framework.Game { GraphicsDeviceManager graphics; SpriteBatch spriteBatch; private List<Texture2D> MelList; private Texture2D mouse; private CatcherHolder m_cHolder; MediaLibrary mediaLibrary; // Грубо говоря "проигрыватель" Song song; // Сама музыка VisualizationData visualizationData; SpriteFont font; private int scores = 0; // очки private float self_size = 1f; // размер "игрока" private int xsize = 1; // множитель очков private float power = 0f; // переменная для пурпурного бонуса private float activity = 0f; // переменная для активности игрока public Game1() { graphics = new GraphicsDeviceManager(this); graphics.PreferredBackBufferWidth = 800; graphics.PreferredBackBufferHeight = 600; graphics.IsFullScreen = false; graphics.ApplyChanges(); Content.RootDirectory = "Content"; // Создаем переменные mediaLibrary = new MediaLibrary(); visualizationData = new VisualizationData(); scores = 0; } protected override void Initialize() { m_cHolder = new CatcherHolder(MelList); MediaPlayer.Play(song); // начинаем играть музыку MediaPlayer.IsVisualizationEnabled = true; // включаем визуализатор base.Initialize(); } protected override void LoadContent() { spriteBatch = new SpriteBatch(GraphicsDevice); MelList = new List<Texture2D>(); for(int a = 1; a <= 5; a++) MelList.Add(Content.Load<Texture2D>("mel" + a)); mouse = Content.Load<Texture2D>("mouse"); song = Content.Load<Song>("Leaves_in_the_Wind"); font = Content.Load<SpriteFont>("SpriteFont1"); } protected override void UnloadContent() { } protected override void Update(GameTime gameTime) { m_cHolder.Update(); MediaPlayer.GetVisualizationData(visualizationData); // получаем данные // "Прогоняем" массив с частотами, выполняем условия for (int a = 0; a < 128; a++) { if (visualizationData.Frequencies[a] > Constanst.BEAT_REACTION && m_cHolder.accomulator[a] > Constanst.ACCOMULATOR_REACTION) { m_cHolder.Beat(a * 3.125f * 2); // вызываем "бит", которые создает частичку. m_cHolder.accomulator[a] -= Constanst.BEAT_COST; // убавляем аккумулятор } } // проверяем, есть ли бонус, который тянет к игроку все ноты if (power > 0f) { for (int particle = 0; particle < m_cHolder.particles.Count; particle++) { if (m_cHolder.particles[particle].type != 1) // если не враг, то тянем { float body1X = m_cHolder.particles[particle].Position.X; float body1Y = m_cHolder.particles[particle].Position.Y; float body2X = (float)Mouse.GetState().X; float body2Y = (float)Mouse.GetState().Y; float Angle = (float)Math.Atan2(body2X - body1X, body2Y - body1Y) - ((float)Math.PI / 2.0f); // находим угол к игроку float Lenght = (float)(5000f * power) / (float)Math.Pow((float)Distance(body1X, body1Y, body2X, body2Y), 2.0f); // находим силу m_cHolder.particles[particle].ApplyImpulse(AngleToV2(Angle, Lenght)); // даем пинка ноте } } power -= 0.001f; // убавляем бонус } activity -= 0.001f; // убавляем активность игрока if (activity < 0.0f) activity = 0.0f; else if (activity > 0.5f) activity = 0.5f; // Держим активность игрока от 0f до .5f // Проверяем столкновения двух кругов: игрока и нот for (int particle = 0; particle < m_cHolder.particles.Count; particle++) { int x = (int)m_cHolder.particles[particle].Position.X; int y = (int)m_cHolder.particles[particle].Position.Y; int radius = (int)(16f * m_cHolder.particles[particle].Size); if (circlesColliding(Mouse.GetState().X, Mouse.GetState().Y, (int)(16f * self_size), x, y, radius)) { scores += (int)(10f * m_cHolder.particles[particle].Size * xsize); // добавляем очки, которые зависят от размера ноты и множителя activity += 0.005f; // добавляем активность int type = m_cHolder.particles[particle].type; // выполняем всякие условия, которые возникают при коллизии switch (type) { case 3: // желтый self_size += 0.1f; xsize += 1; // увеличиваем множитель и размер игрока if (self_size > 4.0f) self_size = 4.0f; break; case 2: // пурпурный power = 1f; // даем бонус игроку, который все ноты притягивает к себе break; case 4: // мигающий for (int b = 0; b < m_cHolder.particles.Count; b++) m_cHolder.particles[b].SetType(3); // устанавливает всем нотам тип — желтый break; case 1: // красный (враг) for(int a = 1; a < xsize; a++) m_cHolder.GenerateYellowExplossion(Mouse.GetState().X, Mouse.GetState().Y, (int)(16f * self_size)); xsize = 1; self_size = 1f; scores -= (int)(scores / 4); break; } // удаляем частичку m_cHolder.particles[particle].TTL = 0; m_cHolder.particles.RemoveAt(particle); particle--; } } base.Update(gameTime); } protected override void Draw(GameTime gameTime) { GraphicsDevice.Clear(Color.Black); m_cHolder.Draw(spriteBatch); // рисуем CatcherHolder spriteBatch.Begin(); Rectangle sourceRectangle = new Rectangle(0, 0, mouse.Width, mouse.Height); // размеры текстуры Vector2 origin = new Vector2(mouse.Width / 2, mouse.Height / 2); // offset текстуры Vector2 mouse_vector = new Vector2(Mouse.GetState().X, Mouse.GetState().Y); // вектор(позиция) мышки string xtext = "x" + xsize.ToString(); // текст Vector2 text_vector = font.MeasureString(xtext) / 2.0f; // вычисления offset'a текста spriteBatch.Draw(mouse, mouse_vector, sourceRectangle, new Color(0.5f - power/2.0f + activity, 0.5f, 0.5f - power/2.0f), 0.0f, origin, self_size, SpriteEffects.None, 0f); // рисуем игрока spriteBatch.DrawString(font, xtext, mouse_vector - text_vector, Color.White); // рисуем множитель spriteBatch.DrawString(font, "Score: " + scores.ToString(), new Vector2(5, graphics.PreferredBackBufferHeight - 34), Color.White); // рисуем очки spriteBatch.End(); base.Draw(gameTime); } // возвращает, столкнулись ли два круга или нет bool circlesColliding(int x1, int y1, int radius1, int x2, int y2, int radius2) { int dx = x2 - x1; int dy = y2 - y1; int radii = radius1 + radius2; if ((dx * dx) + (dy * dy) < radii * radii) { return true; } else { return false; } } // функция перевода угла в вектор public Vector2 AngleToV2(float angle, float length) { Vector2 direction = Vector2.Zero; direction.X = (float)Math.Cos(angle) * length; direction.Y = -(float)Math.Sin(angle) * length; return direction; } // дистанция public float Distance(float x1, float y1, float x2, float y2) { return (float)Math.Sqrt((float)Math.Pow(x2 - x1, 2) + (float)Math.Pow(y2 - y1, 2)); } } }
Вот такая простенькая игрушка получается. На конечной машине пользователя должен быть установлен XNA 4.0 и .NET;
Ссылки: сама игра (директ) | исходники (директ) | XNA Framework End-user
Скриншот:

P.S. Идея не моя, такая игра уже была выпущена под flash. Эта игра писалась исключительно для статьи, соответственно дальнейшего развития она не получит.
P.S.S. Так же помогу разобраться в XNA / уроке, для этого пишите мне в личку на хабре или на контакты, которые есть в профиле.