Система частиц, часть 2

Делаем эмиттер полностью настраиваемым

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

Например, мы не можем зафиксировать конкретное направление генерации частиц или, например, поменять цвет частиц

Давайте вынесем эти значения в поля

public class Emitter
{
    /* ... */

    public int X; // координата X центра эмиттера, будем ее использовать вместо MousePositionX
    public int Y; // соответствующая координата Y 
    public int Direction = 0; // вектор направления в градусах куда сыпет эмиттер
    public int Spreading = 360; // разброс частиц относительно Direction
    public int SpeedMin = 1; // начальная минимальная скорость движения частицы
    public int SpeedMax = 10; // начальная максимальная скорость движения частицы
    public int RadiusMin = 2; // минимальный радиус частицы
    public int RadiusMax = 10; // максимальный радиус частицы
    public int LifeMin = 20; // минимальное время жизни частицы
    public int LifeMax = 100; // максимальное время жизни частицы

    public Color ColorFrom = Color.White; // начальный цвет частицы
    public Color ColorTo = Color.FromArgb(0, Color.Black); // конечный цвет частиц
    
    /* ... */
}

теперь подключим эти параметры в метод сброса частицы

public virtual void ResetParticle(Particle particle)
{
    particle.Life = Particle.rand.Next(LifeMin, LifeMax);

    particle.X = X;
    particle.Y = Y;

    var direction = Direction 
        + (double)Particle.rand.Next(Spreading) 
        - Spreading / 2;

    var speed = Particle.rand.Next(SpeedMin, SpeedMax);

    particle.SpeedX = (float)(Math.Cos(direction / 180 * Math.PI) * speed);
    particle.SpeedY = -(float)(Math.Sin(direction / 180 * Math.PI) * speed);

    particle.Radius = Particle.rand.Next(RadiusMin, RadiusMax);
}

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

/* добавил метод */ 
public virtual Particle CreateParticle()
{
    var particle = new ParticleColorful();
    particle.FromColor = ColorFrom;
    particle.ToColor = ColorTo;

    return particle;
}

/* и подключим его в UpdateState */
public void UpdateState()
{
    foreach (var particle in particles)
    {
        /* ... */ 
    }

    for (var i = 0; i < 10; ++i)
    {
        if (particles.Count < ParticlesCount) 
        {
            var particle = CreateParticle(); // и собственно теперь тут его вызываем
            ResetParticle(particle);
            particles.Add(particle);
        }
        else
        {
            break;
        }
    }
}

Делаем генерацию частиц равномерной

Сейчас мы строго задаем количество частиц, которые можно сгенерировать, но можно настроить систему так чтобы она выдавала фиксированное количество частиц каждый такт,

Добавим новое поле, в котором можно будет указать количество частиц в такт:

public class Emitter
{
    /* ... */
    public int LifeMin = 20;
    public int LifeMax = 100;
    
    public int ParticlesPerTick = 1; // добавил новое поле
    
    /* ... */
}

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

public void UpdateState()
{
    int particlesToCreate = ParticlesPerTick; // фиксируем счетчик сколько частиц нам создавать за тик

    foreach (var particle in particles)
    {
        if (particle.Life <= 0) // если частицы умерла
        {
            /* 
             * то проверяем надо ли создать частицу
             */   
            if (particlesToCreate > 0) 
            {
                /* у нас как сброс частицы равносилен созданию частицы */
                particlesToCreate -= 1; // поэтому уменьшаем счётчик созданных частиц на 1
                ResetParticle(particle);
            }
        }
        else
        {
            /* тут ничего не меняется */
        }
    }
    
    // второй цикл меняем на while, 
    // этот новый цикл также будет срабатывать только в самом начале работы эмиттера
    // собственно пока не накопится критическая масса частиц
    while (particlesToCreate >= 1)
    {
        particlesToCreate -= 1; 
        var particle = CreateParticle();
        ResetParticle(particle);
        particles.Add(particle);
    }
}

Управляем эмиттером

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

И так, давайте поставим один эмиттер на форму.

Тут очень важный момент, что если мы хотим им управлять нам надо эмиттер хранить в поле класса.

Если мы так сделаем, то и из других методов класса можно будет с ним чего-нибудь делать.

Добавляем

public partial class Form1 : Form
{
    List<Emitter> emitters = new List<Emitter>();
    Emitter emitter; // добавим поле для эмиттера

    public Form1()
    {
        InitializeComponent();
        picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);

        this.emitter = new Emitter // создаю эмиттер и привязываю его к полю emitter
        {
            Direction = 0,
            Spreading = 10,
            SpeedMin = 10,
            SpeedMax = 10,
            ColorFrom = Color.Gold,
            ColorTo = Color.FromArgb(0, Color.Red),
            ParticlesPerTick = 10,
            X = picDisplay.Width / 2,
            Y = picDisplay.Height / 2,
        };

        emitters.Add(this.emitter); // все равно добавляю в список emitters, чтобы он рендерился и обновлялся
    }

    /* ... */
}

получится вот так:

тут пока все просто.

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

Вот так:

Теперь заставим наш эмиттер реагировать на изменение ползунка. Для этого щелкнем на него два раза, и в появившемся обработчике напишем:

public Form1()
{
    /* ... */

    private void tbDirection_Scroll(object sender, EventArgs e)
    {
        emitter.Direction = tbDirection.Value; // направлению эмиттера присваиваем значение ползунка 
    }
}

проверяем:

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

и в обработчике

public Form1()
{
    /* ... */

    private void tbDirection_Scroll(object sender, EventArgs e)
    {
        emitter.Direction = tbDirection.Value; 
        lblDirection.Text = $"{tbDirection.Value}°"; // добавил вывод значения
    }
}

получится так

При желании естественно можно добавлять сколько угодно обработчиков и сколько угодно trackbar`ов, например, для изменения разброса

Управляем специальными точками

Добавим пару специальных точек. Например, два гравитона:

public Form1()
{
    InitializeComponent();
        picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);

        this.emitter = new Emitter
        {
            /* ... */
        };

        emitters.Add(this.emitter);

        // добавил гравитон
        emitter.impactPoints.Add(new GravityPoint
        {
            X = picDisplay.Width / 2 + 100,
            Y = picDisplay.Height / 2,
        });
        
        // добавил второй гравитон
        emitter.impactPoints.Add(new GravityPoint
        {
            X = picDisplay.Width / 2 - 100,
            Y = picDisplay.Height / 2,
        });


        /* ... */
    }
}

частицы сразу начнёт интересно закручивать

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

И так, добавляем ползунок

кликнем на него два раза, получим обработчик

private void tbGraviton_Scroll(object sender, EventArgs e)
{

}

чего в него добавлять?

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

private void tbGraviton_Scroll(object sender, EventArgs e)
{
    foreach(var p in emitter.impactPoints)
    {
        if (p is GravityPoint) // так как impactPoints не обязательно содержит поле Power, надо проверить на тип 
        {
            // если гравитон то меняем силу
            (p as GravityPoint).Power = tbGraviton.Value;
        }
    }
}

получится так:

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

Для начала пойдём в IImpactPoint и сделаем метод Render виртуальным

public abstract class IImpactPoint
{
    public float X;
    public float Y;

    public abstract void ImpactParticle(Particle particle);

    public virtual void Render(Graphics g) // добавил слово virtual, ну чтобы override потом можно было юзать
    {
        /* ... */ 
    }
}

а теперь доскроллим до GravityPoint и переопределим этот метод

public class GravityPoint : IImpactPoint
{
    /* ... */

    public override void Render(Graphics g)
    {
        // буду рисовать окружность с диаметром равным Power
        g.DrawEllipse(
               new Pen(Color.Red),
               X - Power / 2,
               Y - Power / 2,
               Power,
               Power
           );
    }
}

запускаем, и от такие очёчки получатся:

Ну и встает вопрос: “А как изменять каждую точку отдельно?”

Да очень просто! Достаточно вынести точки в поля класса и изменять параметры поля:

public partial class Form1 : Form
{
    List<Emitter> emitters = new List<Emitter>();
    Emitter emitter;

    GravityPoint point1; // добавил поле под первую точку
    GravityPoint point2; // добавил поле под вторую точку

    public Form1()
    {
        // от сюда НЕ ТРОГАЕМ
        InitializeComponent();
        picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);

        this.emitter = new Emitter
        {
            /* ... */
        };

        emitters.Add(this.emitter);
        // до сюда НЕ ТРОГАЕМ
        
        // привязываем гравитоны к полям
        point1 = new GravityPoint
        {
            X = picDisplay.Width / 2 + 100,
            Y = picDisplay.Height / 2,
        };
        point2 = new GravityPoint
        {
            X = picDisplay.Width / 2 - 100,
            Y = picDisplay.Height / 2,
        };
        
        // привязываем поля к эмиттеру
        emitter.impactPoints.Add(point1);
        emitter.impactPoints.Add(point2);
    }

    /* ... */
}

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

private void tbGraviton_Scroll(object sender, EventArgs e)
{
    point1.Power = tbGraviton1.Value;
}

private void tbGraviton2_Scroll(object sender, EventArgs e)
{
    point2.Power = tbGraviton2.Value;
}

и вуаля, каждый теперь управляет конкретной точкой

Привязываем гравитон к мышке

Да-да, еще можем заставить точку за мышкой следовать. Тут совсем просто, идем в обработчик MouseMove и добавляем:

private void picDisplay_MouseMove(object sender, MouseEventArgs e)
{
    // это не трогаем
    foreach (var emitter in emitters)
    {
        emitter.MousePositionX = e.X;
        emitter.MousePositionY = e.Y;
    }

    // а тут передаем положение мыши, в положение гравитона
    point2.X = e.X;
    point2.Y = e.Y;
}

такая красота получается:

Как понять, что частица попала в область действия гравитона

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

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

Идем в GravityPoint и правим метод ImpactParticle

public override void ImpactParticle(Particle particle)
{
    float gX = X - particle.X;
    float gY = Y - particle.Y;

    double r = Math.Sqrt(gX * gX + gY * gY); // считаем расстояние от центра точки до центра частицы
    if (r + particle.Radius < Power / 2) // если частица оказалось внутри окружности
    {
        // то притягиваем ее
        float r2 = (float)Math.Max(100, gX * gX + gY * gY);
        particle.SpeedX += gX * Power / r2;
        particle.SpeedY += gY * Power / r2;
    }
}

уиииииии!!! =)

Правим мини баг

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

Заключался он в том, что я меняю положение частицы в эмиттере после применения методов ImpactParticle, а лучше делать это до. И так идем в метод UpdateState

public void UpdateState()
{
    int particlesToCreate = ParticlesPerTick;

    foreach (var particle in particles)
    {
        if (particle.Life <= 0)
        {
            /* ... */
        }
        else
        {
            /* теперь двигаю вначале */
            particle.X += particle.SpeedX;
            particle.Y += particle.SpeedY;
            
            particle.Life -= 1;
            foreach (var point in impactPoints)
            {
                point.ImpactParticle(particle);
            }

            particle.SpeedX += GravitationX;
            particle.SpeedY += GravitationY;
            
            /* это уехало вверх
            particle.X += particle.SpeedX;
            particle.Y += particle.SpeedY; */
        }
    }

    while (particlesToCreate >= 1)
    {
        /* ... */
    }
}

лучше всего это видно на задачке с радаром, вот как выглядит детекция, если двигать частицу до:

а вот как после

то есть детектируются частицы которые визуально не находятся в области

Как текст рисовать

Чтобы рисовать текст, надо использовать метод DrawString.

Например, хочу я добавить текст к гравитону и делаю так

public class GravityPoint : IImpactPoint
{
    /* ... */

    public override void ImpactParticle(Particle particle)
    {
       /* ... */
    }

    public override void Render(Graphics g)
    {
        g.DrawEllipse(/* ... */);

        g.DrawString(
            $"Я гравитон\nc силой {Power}", // надпись, можно перенос строки вставлять (если вы Катя, то может не работать и надо использовать \r\n)
            new Font("Verdana", 10), // шрифт и его размер
            new SolidBrush(Color.White), // цвет шрифта
            X, // расположение в пространстве
            Y
        );
    }
}

если хочется рисовать по центру, то надо использовать специальный класс StringFormat

public class GravityPoint : IImpactPoint
{
    /* ... */

    public override void ImpactParticle(Particle particle)
    {
       /* ... */
    }

    public override void Render(Graphics g)
    {
        g.DrawEllipse(/* ... */);

        var stringFormat = new StringFormat(); // создаем экземпляр класса
        stringFormat.Alignment = StringAlignment.Center; // выравнивание по горизонтали
        stringFormat.LineAlignment = StringAlignment.Center; // выравнивание по вертикали

        g.DrawString(
            $"Я гравитон\nc силой {Power}",
            new Font("Verdana", 10),
            new SolidBrush(Color.White),
            X,
            Y,
            stringFormat // передаем инфу о выравнивании
        );
    }
}

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

В этих целях используется функция MeasureString

public class GravityPoint : IImpactPoint
{
    /* ... */

    public override void ImpactParticle(Particle particle)
    {
       /* ... */
    }

    public override void Render(Graphics g)
    {
        g.DrawEllipse(/* ... */);

        var stringFormat = new StringFormat();
        stringFormat.Alignment = StringAlignment.Center;
        stringFormat.LineAlignment = StringAlignment.Center;
        
        // обязательно выносим текст и шрифт в переменные
        var text = $"Я гравитон\nc силой {Power}";
        var font = new Font("Verdana", 10);

        // вызываем MeasureString, чтобы померить размеры текста
        var size = g.MeasureString(text, font);
        
        // рисуем подложнку под текст
        g.FillRectangle(
            new SolidBrush(Color.Red),
            X - size.Width / 2, // так как я выравнивал текст по центру то подложка должна быть центрирована относительно X,Y
            Y - size.Height / 2,
            size.Width,
            size.Height
        );
        
        // ну и текст рисую уже на базе переменных
        g.DrawString(
            text,
            font,
            new SolidBrush(Color.White),
            X, 
            Y,
            stringFormat
        );
    }
}

вот такая штука получается:

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

Удачи! =)