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

За одно потыкаем работу с графикой в C#, и немного матрицы трансформации.

И так, создаем приложение Windows Forms

вытаскиваем на форму компоненту PictureBox

Теперь добавим событие Paint

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

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

Из события Paint доступ к этому объекту происходит через объект PaintEventArgs e.

public partial class Form1 : Form
{
    // ...

    private void pbMain_Paint(object sender, PaintEventArgs e)
    {
        var g = e.Graphics; // вытащили объект графики из события
    }
}

теперь можно чего-нибудь нарисовать. Например, квадратик

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics; // вытащили объект графики из события
    g.DrawRectangle(new Pen(Color.Red), 200, 100, 50, 30);  // рисуем прямоугольную рамку
}

проверяем

чтобы понять, как объект оказался там, где оказался надо понимать, что в компьютерной графике координата (0, 0) находится в левом верхнем углу. А ось Y направлена вниз. То есть вот так:

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

Для этого есть второй метод FillRectangle

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics; // вытащили объект графики из события
    g.DrawRectangle(new Pen(Color.Red), 200, 100, 50, 30);  // рисуем прямоугольную рамку
    g.FillRectangle(new SolidBrush(Color.Yellow), 200, 100, 50, 30); // залили цвет
}

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

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

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics; // вытащили объект графики из события
    g.FillRectangle(new SolidBrush(Color.Yellow), 200, 100, 50, 30); // сначала фон
    g.DrawRectangle(new Pen(Color.Red, 2/*добавил толщину линии*/), 200, 100, 50, 30);  // а теперь рамку
}

теперь вполне симпотишно:

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

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics;
    
    // залил фон
    g.FillRectangle(new SolidBrush(Color.White), 0, 0, pbMain.Width, pbMain.Height);

    g.FillRectangle(new SolidBrush(Color.Yellow), 200, 100, 50, 30);
    g.DrawRectangle(new Pen(Color.Red, 2), 200, 100, 50, 30);
}

во так

но есть более удобный способ, использовать метод Clear

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics;
    
    // залил фон
    g.Clear(Color.White);

    g.FillRectangle(new SolidBrush(Color.Yellow), 200, 100, 50, 30);
    g.DrawRectangle(new Pen(Color.Red, 2), 200, 100, 50, 30);
}

получится так же, но писать намного приятнее:

Создаем объекты для отображения

Теперь давайте создадим объект, который можно будет рисовать на форме. Создаем папку

назовем ее Objects

теперь создаем класс BaseObject

определим теперь объект, у него будет точка расположения в виде координат X и Y и угол поворота

namespace WinFormsApp11.Objects
{
    class BaseObject
    {
        public float X;
        public float Y;
        public float Angle;
    }
}

добавим сюда конструктор

class BaseObject
{
    public float X;
    public float Y;
    public float Angle;

    public BaseObject(float x, float y, float angle)
    {
        X = x;
        Y = y;
        Angle = angle;
    }
}

идея у меня такая я хочу вынести методы с помощью которых я буду рисовать объект в отдельный метод. Добавлю такой метод:

// ...
using System.Drawing; // добавил using

namespace WinFormsApp11.Objects
{
    class BaseObject
    {
        public float X;
        public float Y;
        public float Angle;

        public BaseObject(float x, float y, float angle)
        {
            /* ... */
        }

        // добавил виртуальный метод для отрисовки
        public virtual void Render(Graphics g)
        {
            // тут пусто
        }
    }
}

теперь давайте добавим новый класс под наш прямоугольник

и запишем в него код

class MyRectangle : BaseObject // наследуем BaseObject
{
    // создаем конструктор с тем же набором параметров что и в BaseObject
    // base(x, y, angle) -- вызывает конструктор родительского класса
    public MyRectangle(float x, float y, float angle) : base(x, y, angle)
    {
    }
    
    // переопределяем Render
    public override void Render(Graphics g)
    {
        // и запихиваем туда код из формы
        g.FillRectangle(new SolidBrush(Color.Yellow), 200, 100, 50, 30);
        g.DrawRectangle(new Pen(Color.Red, 2), 200, 100, 50, 30);
    }
}

теперь давайте воспользуемся этим объектом для отрисовки на форме

public partial class Form1 : Form
{
    MyRectangle myRect; // создадим поле под наш прямоугольник

    public Form1()
    {
        InitializeComponent();
        myRect = new MyRectangle(0, 0, 0); // создать экземпляр класса
    }

    private void pbMain_Paint(object sender, PaintEventArgs e)
    {
        var g = e.Graphics;
        
        g.Clear(Color.White);
        
        /* УБИРАЕМ
        g.FillRectangle(new SolidBrush(Color.Yellow), 200, 100, 50, 30);
        g.DrawRectangle(new Pen(Color.Red, 2), 200, 100, 50, 30);
        */
        myRect.Render(g); // теперь так рисуем
    }
}

если запустить все должно работать как раньше

сейчас у меня координаты X и Y не используются, а хотелось, чтобы использовались.

Давайте перепишем метод render так чтобы он рисовал квадрат в точке (0, 0)

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

Двигаем объект к с помощью матрицы трансформаций

теперь допустим я хочу сместить объект в точку (100, 100) но сделать это через поля X, Y.

Давайте поменяем координаты в конструкторе

пока это никак не повиляет на отображение

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

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

Так что давайте вернем все обратно

Ну и тут встает закономерный вопрос, как его тогда сдвинуть?

Для этого класс Graphics поддерживает работы с матрицами трансформации. С помощью матрицы трансформации, можно изменять положение объектов при выводе. То есть идея такая, вы применяете матрицу трансформации со сдвигом на x=100, y=100 и после этого любой метод FillRectangle, DrawRectangle и т.д. при выводе себя автоматом сдвигают свои точки на x=100, y=100

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics;
    
    g.Clear(Color.White);
    
    // вытаскиваем матрицу преобразования Graphics
    var matrix = g.Transform;
    matrix.Translate(myRect.X, myRect.Y); // смещаем ее в пространстве
    g.Transform = matrix; // устанавливаем новую матрицу

    myRect.Render(g); // рендерим как обычно
}

объект и правда сместился:

причем напоминаю, что в MyRectangle мы рисуем объект в точке 0

можно конечно возразить, а в чем преимущество то, ведь теперь чтобы сдвинуть объект нам надо целых три строчки писать. А преимущество в том, что мы, например, помимо сдвига объекта можем его еще и крутить, например, так

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

кстати намного удобнее формировать эту матрицу прямо внутри BaseObject

и теперь можно так рисовать

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

тогда при выводе объект крутиться будет более логично

Выводим несколько объектов

Теперь допустим я хочу выводить много объектов. Давайте заведем под это дело список объектов и будем все их выводить в цикле:

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

Добавляем игрока

Создаем новый класс Player

теперь добавляем на форму

Перемещаем игрока

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

Сначала для этого я создам специальный объект который назову Marker

и добавлю его на форму:

глянем что получится:

теперь мне надо как-то сделать что объект игрока начал двигаться в сторону маркера.

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

Специально для таких целей есть специальная компонента Timer, которую можно добавить на форму привязать к ней функции и указать вызывать эту функцию каждые n милисекунд.

тыкаем два раза на timer1

и пишем код

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

действительно начала двигаться, только его в конце начинает трясти, из-за того, что по достижению маркера он начинает пытаться точно спозиционироваться на него и из-за этого постоянно смещается то выше то ниже него.

Давайте теперь добавим возможность менять положение маркера кликом мыши.

Идем на форму и добавляем pbMain событие клика мыши

пишем в обработчик

запускаем и пробуем тыкать:

прикольно! =)

Работаем с событиями

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

В C# есть возможность описать форму объекта в двумерном пространстве в виде набора примитивов. Имея два таких описания можно считать форму получающуюся как пересечение двух форм и таким образом понимать было наложение двух форм или нет.

Давайте создадим метод с помощью которого можно будет описать форму объекта. Возвращать он будет экземпляр класса GraphicsPath

class BaseObject
{
    // ...
    
    public virtual GraphicsPath GetGraphicsPath()
    {
        // пока возвращаем пустую форму
        return new GraphicsPath();
    }
}

теперь создадим метод который будет проверять пересечение двух форм

class BaseObject
{
    // ...

    // так как пересечение учитывает толщину линий и матрицы трансформацией
    // то для того чтобы определить пересечение объекта с другим объектом
    // надо передать туда объект Graphics, это не очень удобно 
    // но в учебных целях реализуем так
    public virtual bool Overlaps(BaseObject obj, Graphics g)
    {
        // берем информацию о форме
        var path1 = this.GetGraphicsPath();
        var path2 = obj.GetGraphicsPath();
        
        // применяем к объектам матрицы трансформации
        path1.Transform(this.GetTransform());
        path2.Transform(obj.GetTransform());

        // используем класс Region, который позволяет определить 
        // пересечение объектов в данном графическом контексте
        var region = new Region(path1);
        region.Intersect(path2); // пересекаем формы
        return !region.IsEmpty(g); // если полученная форма не пуста то значит было пересечение
    }
}

теперь давайте подкрутим нашего игрока и маркер так чтобы у них появилось описание формы

и добавим описание формы для маркера:

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

и добавим в форму фиксацию пересечения

public partial class Form1 : Form
{
    // ...

    private void pbMain_Paint(object sender, PaintEventArgs e)
    {
        var g = e.Graphics;
        
        g.Clear(Color.White);

        foreach(var obj in objects)
        {           
            // проверяю было ли пересечение с игроком
            if (obj != player && player.Overlaps(obj, g))
            {
                // и если было вывожу информацию на форму
                txtLog.Text = $"[{DateTime.Now:HH:mm:ss:ff}] Игрок пересекся с {obj}\n" + txtLog.Text;
            }
            
            g.Transform = obj.GetTransform();
            obj.Render(g);
        }
    }

попробуем теперь запустить

ну в принципе не плохо.

Теперь я хочу сделать чтобы у меня маркер удалялся по достижению чтобы не было этого не приятного дрожания. Делается вот так:

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics;
    
    g.Clear(Color.White);

    // меняю тут objects на objects.ToList()
    // это будет создавать копию списка
    // и позволит модифицировать оригинальный objects прямо из цикла foreach
    foreach (var obj in objects.ToList())
    {
        if (obj != player && player.Overlaps(obj, g))
        {
            // это не трогаю
            txtLog.Text = $"[{DateTime.Now:HH:mm:ss:ff}] Игрок пересекся с {obj}\n" + txtLog.Text;

            // тут проверяю что достиг маркера
            if (obj == marker)
            {
                // если достиг, то удаляю маркер из оригинального objects
                objects.Remove(marker);
                marker = null; // и обнуляю маркер
            }
        }

        g.Transform = obj.GetTransform();
        obj.Render(g);
    }
}

private void timer1_Tick(object sender, EventArgs e)
{
    // тут добавляем проверку на marker не нулевой
    if (marker != null) { 
        float dx = marker.X - player.X;
        float dy = marker.Y - player.Y;

        float length = MathF.Sqrt(dx * dx + dy * dy);
        dx /= length;
        dy /= length;

        player.X += dx * 2; 
        player.Y += dy * 2;
    }

    // запрашиваем обновление pbMain
    // это вызовет метод pbMain_Paint по новой
    pbMain.Invalidate();
}

private void pbMain_MouseClick(object sender, MouseEventArgs e)
{
    // тут добавил создание маркера по клику если он еще не создан
    if (marker == null)
    {
        marker = new Marker(0, 0, 0);
        objects.Add(marker); // и главное не забыть пололжить в objects
    }

    // а это так и остается
    marker.X = e.X;
    marker.Y = e.Y;
}

пробуем:

и в принципе ничего так получается.

Давайте теперь воспользуемся делегатами для декомпозиции. Идея работы с событиями заключается в том, что вы реакцию на то или иной событие прописываете заранее, а потом в каком-нибудь большом методе происходит генерация этих событий.

У нас, например, сейчас есть событие пересечения игрока с другим объектом.

Давайте создадим поля типа делегат в методе BaseObject который будет отражать это событие

class BaseObject
{
    public float X;
    public float Y;
    public float Angle;

    // добавил поле делегат, к которому можно будет привязать реакцию на собыития
    public Action<BaseObject, BaseObject> OnOverlap;
    
    // ...
}

как правило создавая поле делегат, вам надо создать еще и метод, с помощью которого будет вызываться это событие

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

Идем в конструктор формы и прописываем там:

public Form1()
{
    InitializeComponent();

    player = new Player(pbMain.Width / 2, pbMain.Height / 2, 0);
    // добавляю реакцию на пересечение
    player.OnOverlap += (p, obj) =>
    {
        txtLog.Text = $"[{DateTime.Now:HH:mm:ss:ff}] Игрок пересекся с {obj}\n" + txtLog.Text;
    };
    
    // остальное не трогаем
    marker = new Marker(pbMain.Width / 2 + 50, pbMain.Height / 2 + 50, 0);

    // ...
}

и идем теперь в pbMain_Paint и правим его так:

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics;
    
    g.Clear(Color.White);

    foreach (var obj in objects.ToList())
    {
        if (obj != player && player.Overlaps(obj, g))
        {
            /* УДАЛЯЮ ТУТ 
               txtLog.Text = $"[{DateTime.Now:HH:mm:ss:ff}] Игрок пересекся с {obj}\n" + txtLog.Text; */

            // а вот эти строчки добавляем
            player.Overlap(obj); // то есть игрок пересекся с объектом
            obj.Overlap(player); // и объект пересекся с игроком

            if (obj == marker)
            {
                objects.Remove(marker);
                marker = null;
            }
        }

        g.Transform = obj.GetTransform();
        obj.Render(g);
    }
}

если запустить, то работать будет как обычно

давайте теперь создадим еще одно событие “Пересечение с маркером” но уже прямо игроку добавим его

и теперь в конструкторе формы прописываем:

public Form1()
{
    InitializeComponent();

    player = new Player(pbMain.Width / 2, pbMain.Height / 2, 0);
    player.OnOverlap += (p, obj) =>
    {
        txtLog.Text = $"[{DateTime.Now:HH:mm:ss:ff}] Игрок пересекся с {obj}\n" + txtLog.Text;
    };
    
    // добавил реакцию на пересечение с маркером
    player.OnMarkerOverlap += (m) =>
    {
        objects.Remove(m);
        marker = null;
    };

    // ...
}

а Paint методе удаляем работу с маркером:

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics;
    
    g.Clear(Color.White);

    foreach (var obj in objects.ToList())
    {
        if (obj != player && player.Overlaps(obj, g))
        {
            player.Overlap(obj); 
            obj.Overlap(player);
            
            /* удаляю это
            if (obj == marker)
            {
                objects.Remove(marker);
                marker = null;
            }*/
        }

        g.Transform = obj.GetTransform();
        obj.Render(g);
    }
}

получается вполне себе короткий метод:

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics;
    
    g.Clear(Color.White);

    foreach (var obj in objects.ToList())
    {
        if (obj != player && player.Overlaps(obj, g))
        {
            player.Overlap(obj); 
            obj.Overlap(player);
        }

        g.Transform = obj.GetTransform();
        obj.Render(g);
    }
}

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

private void pbMain_Paint(object sender, PaintEventArgs e)
{
    var g = e.Graphics;
    
    g.Clear(Color.White);

    // пересчитываем пересечения
    foreach (var obj in objects.ToList())
    {
        if (obj != player && player.Overlaps(obj, g))
        {
            player.Overlap(obj); 
            obj.Overlap(player);
        }
    }

    // рендерим объекты
    foreach (var obj in objects)
    {
        g.Transform = obj.GetTransform();
        obj.Render(g);
    }
}

в принципе с этим уже можно делать задания

[Бонус] Делаем движение игрока более плавным

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

Для этого добавим ему поля под вектор скорости.

class Player : BaseObject
{
    public Action<Marker> OnMarkerOverlap;
    public float vX, vY;
    
    // ...
}

и теперь подправим расчет положения игрока

private void timer1_Tick(object sender, EventArgs e)
{
    if (marker != null) { 
        float dx = marker.X - player.X;
        float dy = marker.Y - player.Y;
        float length = MathF.Sqrt(dx * dx + dy * dy);
        dx /= length;
        dy /= length;

        // по сути мы теперь используем вектор dx, dy
        // как вектор ускорения, точнее даже вектор притяжения
        // который притягивает игрока к маркеру
        // 0.5 просто коэффициент который подобрал на глаз
        // и который дает естественное ощущение движения
        player.vX += dx * 0.5f; 
        player.vY += dy * 0.5f;
        
        // расчитываем угол поворота игрока 
        player.Angle = 90 - MathF.Atan2(player.vX, player.vY) * 180 / MathF.PI;
    }

    // тормозящий момент,
    // нужен чтобы, когда игрок достигнет маркера произошло постепенное замедление
    player.vX += -player.vX * 0.1f;
    player.vY += -player.vY * 0.1f;

    // пересчет позиция игрока с помощью вектора скорости
    player.X += player.vX;
    player.Y += player.vY;

    pbMain.Invalidate();
}

вообще давайте сделаем отдельный метод под этот расчет

да и в целом, наверное, логично утащить его тогда Paint метод, раз уж мы там всю логику считаем

глянем как работает:

вполне ничего так)