Pull to refresh

Знакомство с XNA и написание первой музыкальной игры

Game development *C# *
Tutorial
Привет всем начинающим геймдевелоперам и просто хорошим людям. Сегодня, я хочу познакомить вас с замечательным фреймворком 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 / уроке, для этого пишите мне в личку на хабре или на контакты, которые есть в профиле.
Tags:
Hubs:
Total votes 70: ↑60 and ↓10 +50
Views 40K
Comments Comments 28