- Создаем объекты для отображения
- Двигаем объект к с помощью матрицы трансформаций
- Выводим несколько объектов
- Добавляем игрока
- Работаем с событиями
- [Бонус] Делаем движение игрока более плавным
Сегодня будем разбираться как работать с делегатами на практики, как создавать события и подписываться на них.
За одно потыкаем работу с графикой в 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 метод, раз уж мы там всю логику считаем
глянем как работает:
вполне ничего так)