Skip to main content

07. Система координат

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

w

Пример готового проекта лежит здесь. Для выполнения текущего задания 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();
}

Запустим программу. Теперь в центре рисуется скруглённый квадрат.

w

Чтобы его нарисовать, методу 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.

w

Подробно разбирать этот класс не будет. Методы в нём довольно тривиальны и задокументированы

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.

w

В появившемся меню выбираем equals() and hashCode().

В следующем окне нам нужно выбрать схему, согласно которой формируется документация. Мне больше нравится документация по умолчанию от Intellij, но вы можете выбрать и другую. Они все работают, а разница для данного проекта несущественна.

w

В следующем окне мы указываем, какие поля должны быть задействованы в методе equals()

w

В следующем окне - в методе hashCode()

w

Idea сформирует новые методы за нас

w

Создайте новый коммит int vector и отправьте его на сервер.

Ограниченная система координат

В том же пакете misc создадим класс ограниченной целочисленной системы координат CoordinateSystem2i

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()));
}
}

Да, пока что метод особо не изменился, но удобство использования ОСК (ограниченной системы координат) станет ощутимее в следующих главах.

Запустим ещё раз приложение и убедимся, что всё работает корректно

w

Создайте новый коммит int cs и отправьте его на сервер.

Задание

Пример готового проекта лежит здесь. Для выполнения первого задания Fork от него делать запрещается.

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

Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте Fork готового проекта из предыдущей главы.

Вам необходимо создать свой репозиторий, в котором добавлены следующие коммиты (для каждого сначала указывается название, потом требования):

  1. drawign rect works - по сравнению с предыдущим приложением, новое должно рисовать квадрат в центре экрана
  2. int vector - добавьте класс целочисленного вектора из примера
  3. int cs - перепишите логику рисования на векторы и ограниченную систему координат

w

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

Ссылка на контест