07. Система координат
В прошлой главе мы запустили всего одну команду рисования прямо внутри обработчика событий. Если команда одна, то такой подход оправдан. Но мы планируем делать интерфейс, а значит, команд будет много больше одной.
Пример готового проекта лежит здесь.
Для выполнения текущего задания Fork
от него делать запрещается.
В ходе работы вам будет нужно сделать несколько коммитов с промежуточными состояниями проекта, поэтому в прямом копировании нет смысла. Готовый проект даётся для того, чтобы было проще разобраться, как выполнить поставленное задание.
Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте
Fork
готового проекта из предыдущей главы.
Рисование
Вынесем рисование в отдельный метод Application.paint()
:
/**
* Рисование
*
* @param canvas низкоуровневый инструмент рисования примитивов от Skija
* @param height высота окна
* @param width ширина окна
*/
public void paint(Canvas canvas, int height, int width) {
// очищаем канвас
canvas.clear(APP_BACKGROUND_COLOR);
}
Теперь обработчик событий будет таким:
/**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// если событие - это закрытие окна
if (e instanceof EventWindowClose) {
// завершаем работу приложения
App.terminate();
} else if (e instanceof EventWindowCloseRequest) {
window.close();
} else if (e instanceof EventFrameSkija ee) {
Surface s = ee.getSurface();
paint(s.getCanvas(), s.getWidth(), s.getHeight());
}
}
Само рисование немного расширим так, чтобы рисовался квадрат:
/**
* Рисование
*
* @param canvas низкоуровневый инструмент рисования примитивов от Skija
* @param height высота окна
* @param width ширина окна
*/
public void paint(Canvas canvas, int width, int height) {
// запоминаем изменения (пока что там просто заливка цветом)
canvas.save();
// очищаем канвас
canvas.clear(APP_BACKGROUND_COLOR);
// координаты левого верхнего края окна
int rX = width / 3;
int rY = height / 3;
// ширина и высота
int rWidth = width / 3;
int rHeight = height / 3;
// создаём кисть
Paint paint = new Paint();
// задаём цвет рисования
paint.setColor(Misc.getColor(100, 255, 255, 255));
// рисуем квадрат
canvas.drawRRect(RRect.makeXYWH(rX, rY, rWidth, rHeight, 4), paint);
// восстанавливаем состояние канваса
canvas.restore();
}
Запустим программу. Теперь в центре рисуется скруглённый квадрат.
Чтобы его нарисовать, методу canvas.drawRRect()
передаётся объект скруглённого квадрата и кисти.
Скруглённый квадрат определяется координатой левого верхнего края окна, шириной, высотой и радиусом
скругления в пикселях.
Каждое сохранение выполняется отдельно и в действительности canvas.save()
возвращает именно
номер сохранённого состояния.
В Skija заимствован подход стека матриц состояния из OpenGL. Общая идея в том, что любая команда по трансформации области рисования сохраняются в соответствующих матрицах.
Трансформация - это например смещение или поворот. Каждый раз, когда мы
вызываем метод canvas.save()
сохраняется текущая матрица преобразования, потом мы можем изменять
область рисования, но в любой момент можем восстановить её состояние из сохранённого
с помощью команды canvas.restore()
.
Если у нас проходит цепочка сохранений, а потом мы сразу же хотим вернуться
к конкретному состоянию, минуя промежуточные, то нам нужно использовать команду canvas.restoreToCount()
Это - демонстрационный блок кода, копировать его никуда не нужно
...
// сохраняем состояние области рисования
int count = canvas.save();
...
// сохраняем
canvas.save()
...
// восстанавливаем сразу первое состояние
canvas.restoreToCount(count);
...
Без такого метода, нам пришлось бы два раза вызвать команду canvas.restore()
.
Т.к. состояния области рисования сохраняются в стек, то очень важно каждое добавленное состояние
извлекать из него. Т.е. на каждый canvas.save()
должен быть вызван canvas.restore()
Создайте новый коммит drawign rect works
и отправьте его на сервер.
Целочисленный вектор
В дальнейшем мы много раз будем работать со структурами, определёнными схожим образом.
Можно было бы унаследоваться от класса квадрата Rect
или скруглённого квадрата RRect
, но
у него довольно много методов, в рамках обучения проще создать новую структуру ограниченной
системы координат. Такие системы координат помимо базисных векторов-ортов определяются ещё
и диапазоном допустимых значений каждой из координат.
Система координат окна как раз является ограниченной, если считать, что всё, что выходит за границы окна не отображается.
Чтобы удобнее было работать с такой системой координат, определим в первую очередь класс двумерного
целочисленного вектора Vector2i
.
Подробно разбирать этот класс не будет. Методы в нём довольно тривиальны и задокументированы
package misc;
import io.github.humbleui.jwm.Event;
import io.github.humbleui.jwm.EventMouseMove;
import java.util.concurrent.ThreadLocalRandom;
/**
* Класс двумерного вектора int
*/
public class Vector2i {
/**
* x - координата вектора
*/
public int x;
/**
* y - координата вектора
*/
public int y;
/**
* Конструктор вектора
*
* @param x координата X вектора
* @param y координата Y вектора
*/
public Vector2i(int x, int y) {
this.x = x;
this.y = y;
}
/**
* Конструктор вектора от события
*
* @param e событие
*/
public Vector2i(Event e) {
// если событие кнопка мыши
if (e instanceof EventMouseMove ee) {
this.x = ee.getX();
this.y = ee.getY();
}
}
/**
* Сложить два вектора
*
* @param a первый вектор
* @param b второй вектор
* @return сумма двух векторов
*/
public static Vector2i sum(Vector2i a, Vector2i b) {
return new Vector2i(a.x + b.x, a.y + b.y);
}
/**
* Добавить вектор к текущему вектору
*
* @param v вектор, который нужно добавить
*/
public void add(Vector2i v) {
this.x = this.x + v.x;
this.y = this.y + v.y;
}
/**
* Вычесть второй вектор из первого
*
* @param a первый вектор
* @param b второй вектор
* @return разность двух векторов
*/
public static Vector2i subtract(Vector2i a, Vector2i b) {
return new Vector2i(a.x - b.x, a.y - b.y);
}
/**
* Получить случайное значение в заданном диапазоне [min,max)
*
* @param min нижняя граница
* @param max верхняя граница
* @return случайное значение в заданном диапазоне [min,max)
*/
public static Vector2i rand(Vector2i min, Vector2i max) {
return new Vector2i(
ThreadLocalRandom.current().nextInt(min.x, max.x),
ThreadLocalRandom.current().nextInt(min.y, max.y)
);
}
/**
* Увеличить каждую из координат на 1
*/
public void inc() {
this.x++;
this.y++;
}
/**
* уменьшить каждую из координат на 1
*/
public void dec() {
this.x--;
this.y--;
}
/**
* Получить длину вектора
*
* @return длина вектора
*/
public double length() {
return Math.sqrt(x * x + y * y);
}
/**
* Строковое представление объекта
*
* @return строковое представление объекта
*/
@Override
public String toString() {
return "(" + x +
", " + y +
')';
}
/**
* Проверка двух объектов на равенство
*
* @param o объект, с которым сравниваем текущий
* @return флаг, равны ли два объекта
*/
@Override
public boolean equals(Object o) {
// если объект сравнивается сам с собой, тогда объекты равны
if (this == o) return true;
// если в аргументе передан null или классы не совпадают, тогда объекты не равны
if (o == null || getClass() != o.getClass()) return false;
// приводим переданный в параметрах объект к текущему классу
Vector2i vector2i = (Vector2i) o;
// если не совпадают x координаты
if (x != vector2i.x) return false;
// объекты совпадают тогда и только тогда, когда совпадают их координаты
return y == vector2i.y;
}
/**
* Получить хэш-код объекта
*
* @return хэш-код объекта
*/
@Override
public int hashCode() {
int result = x;
result = 31 * result + y;
return result;
}
}
Отдельно рассмотрим только конструктор и некоторые неочевидные методы
Я добавил конструктор от событий движка, чтобы из них можно было проще получать координаты.
Также в методе, возвращающем случайный вектор в заданном диапазоне, используется
метод ThreadLocalRandom.current()
, обычно его рекомендуют использовать для многопоточных приложений,
но я советую сразу привыкать к нему, т.к. и в однопоточных приложениях он тоже
очень удобен. Чтобы использовать обычный рандом, нужно создавать объект класса Random
и
уже работать с ним.
У части методов в описании добавилось новая команда @return
. С её помощи описывается, что именно
возвращает документируемый метод.
Рассмотрим подробно последние два метода.
equals(Object o)
отвечает за проверку на равенство двух объектов. Вначале мы проверяем особые случаи,
приводим объект к текущему классу, а потом уже выполняем все проверки.
В аргументах мы передаём объект именно класса Object
, а не текущего, т.к. мы перегружаем
метод equals()
, а в родителе, т.е. в классе Object
он определён с аргументом именно
equals(Object o)
.
Когда вы сами будете писать проверку, сравнивайте не все поля, а только действительно важные. В данном случае поля всего два и оба важны при проверке на равенство. Но дальше нам встретятся классы, у которых не нужно сравнивать все поля потому что они либо служебные, либо определяются через другие поля.
Второй метод возвращает хэш объекта. О хэшах было рассказано в главах Git.
Эти два метода строятся всегда по одной и той же методике, поэтому в idea есть их автогенерация.
Для этого перевидите курсор внутрь класса, но снаружи методов и нажмите Alt+Insert
.
В появившемся меню выбираем equals() and hashCode()
.
В следующем окне нам нужно выбрать схему, согласно которой формируется документация. Мне больше нравится документация по умолчанию от Intellij, но вы можете выбрать и другую. Они все работают, а разница для данного проекта несущественна.
В следующем окне мы указываем, какие поля должны быть задействованы в методе equals()
В следующем окне - в методе hashCode()
Idea
сформирует новые методы за нас
Создайте новый коммит int vector
и отправьте его на сервер.
Ограниченная система координат
В том же пакете misc
создадим класс ограниченной целочисленной системы координат CoordinateSystem2i
- CoordinateSystem2i.java
package misc;
import io.github.humbleui.skija.RRect;
import io.github.humbleui.skija.Rect;
import java.util.Objects;
/**
* Ограниченная двумерная целочисленная система координат
*/
public class CoordinateSystem2i {
/**
* максимальная координата
*/
private final Vector2i max;
/**
* минимальная координата
*/
private final Vector2i min;
/**
* размер СК
*/
private final Vector2i size;
/**
* Конструктор ограниченной двумерной целочисленной системы координат
*
* @param minX минимальная X координата
* @param minY минимальная Y координата
* @param sizeX размер по оси X
* @param sizeY размер по оси Y
*/
public CoordinateSystem2i(int minX, int minY, int sizeX, int sizeY) {
min = new Vector2i(minX, minY);
size = new Vector2i(sizeX, sizeY);
max = Vector2i.sum(size, min);
max.dec();
}
/**
* Конструктор ограниченной двумерной целочисленной системы координат
*
* @param sizeX размер по оси X
* @param sizeY размер по оси Y
*/
public CoordinateSystem2i(int sizeX, int sizeY) {
this(0, 0, sizeX, sizeY);
}
/**
* Получить случайные координаты внутри СК
*
* @return случайные координаты внутри СК
*/
public Vector2i getRandomCoords() {
return Vector2i.rand(min, max);
}
/**
* Возвращает относительное положение вектора в СК
*
* @param pos положение
* @return относительное положение
*/
public Vector2i getRelativePos(Vector2i pos) {
return Vector2i.subtract(pos, min);
}
/**
* Получить квадрат по СК
*
* @return квадрат
*/
public Rect getRect() {
return Rect.makeXYWH(min.x, min.y, size.x, size.y);
}
/**
* Получить скруглённый квадрат по СК
*
* @param rad радиус скругления
* @return квадрат
*/
public RRect getRRect(float rad) {
return RRect.makeXYWH(min.x, min.y, size.x, size.y, rad);
}
/**
* Проверить, попадают ли координаты в границы СК
*
* @param coords координаты вектора
* @return флаг, попадают ли координаты в границы СК
*/
public boolean checkCoords(Vector2i coords) {
return checkCoords(coords.x, coords.y);
}
/**
* Проверить, попадают ли координаты в границы СК
*
* @param x координата X
* @param y координата Y
* @return флаг, попадают ли координаты в границы СК
*/
public boolean checkCoords(int x, int y) {
return x > min.x && y > min.y && x < max.x && y < max.y;
}
/**
* Получить максимальную координата
*
* @return максимальная координата
*/
public Vector2i getMax() {
return max;
}
/**
* Получить минимальную координата
*
* @return минимальная координата
*/
public Vector2i getMin() {
return min;
}
/**
* Получить размер СК
*
* @return размер СК
*/
public Vector2i getSize() {
return size;
}
/**
* Строковое представление объекта вида:
*
* @return "CoordinateSystem2i{min, max}"
*/
@Override
public String toString() {
return "CoordinateSystem2i{" + min + ", " + max + '}';
}
/**
* Проверка двух объектов на равенство
*
* @param o объект, с которым сравниваем текущий
* @return флаг, равны ли два объекта
*/
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CoordinateSystem2i that = (CoordinateSystem2i) o;
if (!Objects.equals(max, that.max)) return false;
return Objects.equals(min, that.min);
}
/**
* Получить хэш-код объекта
*
* @return хэш-код объекта
*/
@Override
public int hashCode() {
int result = max != null ? max.hashCode() : 0;
result = 31 * result + (min != null ? min.hashCode() : 0);
return result;
}
}
В этом классе многие методы тоже довольно тривиальны. Отдельно стоит остановиться всего на нескольких.
getRect()
и getRRect()
предназначены для упрощения связывания нашей системы координат с
движком. В методах equals()
и hashCode()
не задействовано поле size
, потому что оно всегда
представимо через поля min
и max
.
Перепишем теперь класс приложения с использованием нашего нового класса.
Обработчик событий Skija теперь будет таким
Теперь метод рисования в классе приложения Application
можно переписать с использованием ОСК:
/**
* Рисование
*
* @param canvas низкоуровневый инструмент рисования примитивов от Skija
* @param windowCS СК окна
*/
public void paint(Canvas canvas, CoordinateSystem2i windowCS) {
// запоминаем изменения (пока что там просто заливка цветом)
canvas.save();
// очищаем канвас
canvas.clear(APP_BACKGROUND_COLOR);
// создаём кисть
Paint paint = new Paint();
// задаём цвет рисования
paint.setColor(Misc.getColor(100, 255, 255, 255));
CoordinateSystem2i rectCS = new CoordinateSystem2i(
windowCS.getSize().x / 3, windowCS.getSize().y / 3,
windowCS.getSize().x / 3, windowCS.getSize().y / 3
);
// рисуем квадрат
canvas.drawRRect(rectCS.getRRect(4), paint);
// восстанавливаем состояние канваса
canvas.restore();
}
теперь необходимо изменить сам обработчик события рисования
/**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// если событие - это закрытие окна
if (e instanceof EventWindowClose) {
// завершаем работу приложения
App.terminate();
} else if (e instanceof EventWindowCloseRequest) {
window.close();
} else if (e instanceof EventFrameSkija ee) {
// получаем поверхность рисования
Surface s = ee.getSurface();
// очищаем её канвас заданным цветом
paint(s.getCanvas(), new CoordinateSystem2i(s.getWidth(), s.getHeight()));
}
}
Да, пока что метод особо не изменился, но удобство использования ОСК (ограниченной системы координат) станет ощутимее в следующих главах.
Запустим ещё раз приложение и убедимся, что всё работает корректно
Создайте новый коммит int cs
и отправьте его на сервер.
Задание
Пример готового проекта лежит здесь.
Для выполнения первого задания Fork
от него делать запрещается.
В ходе работы вам будет нужно сделать несколько коммитов с промежуточными состояниями проекта, поэтому в прямом копировании нет смысла. Готовый проект даётся для того, чтобы было проще разобраться, как выполнить поставленное задание.
Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте
Fork
готового проекта из предыдущей главы.
Вам необходимо создать свой репозиторий, в котором добавлены следующие коммиты (для каждого сначала указывается название, потом требования):
drawign rect works
- по сравнению с предыдущим приложением, новое должно рисовать квадрат в центре экранаint vector
- добавьте класс целочисленного вектора из примераint cs
- перепишите логику рисования на векторы и ограниченную систему координат
Ссылку на github
репозиторий необходимо отправить в поле ввода задания на сайте mdl
.