Реализация drag&drop на Java в Processing

  • match-three game
  • java
  • processing
  • tutorial

Сегодня в рамках своего мини-проекта по разработке match3-игры я подготовил туториал о реализации механизма drag&drop в Processing. Для разработки в этой среде используется упрощенная версия языка Java (здесь нет импортов и, вероятно, еще каких-то крутых штук, о которых я пока что не знаю).

Сразу хочу предупредить, что я никогда раньше не работал с Java, поэтому я могу делать какие-то вещи неправильно или ужасно. Если вы заметили ошибку в коде или знаете, что можно улучшить, напишите мне на почту: agorshkov+java@mznx.ru. Обязательно укажите в теме письма "Отзыв на туториал о drag&drop", чтобы я не пропустил ваше письмо и мог оперативно ответить на все ваши вопросы.

Итак, приступим. Для начала создадим новый скетч и назовем его как-нибудь, например DragAndDropSketch. Здесь необходимо определить два метода: setup() и draw(). Первый выполняется при запуске программы, а второй — это бесконечный цикл (как loop() в Arduino) для отрисовки каких-то объектов (GUI, текст, фигуры и т.д.).

void setup() {
}

void draw() {
}

Абстракции

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

• Drawable будет использоваться для объектов, которые могут вывести себя на экран;

Updatable будет использоваться для объектов, у которых в процессе работы может меняться состояние;

MouseInteractable используется для объектов, с которыми может быть взаимодействие мышью;

• Интерфейс Movable реализуется объектами, имеющими координаты. Также эти объекты могут быть перемещены, о чем и говорит название;

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

interface Drawable {
  public void draw();
}

interface Updatable {
  public void update();
}

interface MouseInteractable extends Movable {  
  public void onMousePressed(MouseEvent event);
  
  public void onMouseReleased(MouseEvent event);
  
  public boolean isMouseOver();
}

interface Movable {
  public Position getPosition();
  
  public void move(int x, int y);
  
  public void move(int x, int y, int z);
}

interface Resizable {
  public Size getSize();
  
  public void resize(int width, int height);
}

Куча интерфейсов необходима для соблюдения принципа разделения интерфейса (буква I из аббревиатуры SOLID), однако стоит заметить, что перемещаемые объекты должны реализовывать каждый из этих интерфейсов, т.к. они могут быть отрисованы (Drawable), меняют свое состояние (Updatable), взаимодействуют с мышью (MouseInteractable) и имеют положение на экране и размер. Соответственно, чтобы не перечислять все эти интерфейсы для каждого конкретного объекта, введем еще один интерфейс. Object:

interface Object extends Movable, Resizable, Updatable, Drawable {
}

Стоит заметить, что Object не расширяет интерфейс MouseInteractable. Почему это сделано, вы увидете дальше, а сначала давайте напишем создадим классы MouseEvent, Size и Position:

class Size {
  public int width, height;
  
  Size(int width, int height) {
    this.width = width;
    this.height = height;
  }
}

class Position {
  public int x, y, z;
  
  Position(int x, int y, int z) {
    this.x = x;
    this.y = y;
    this.z = z;
  }
  
  Position(int x, int y) {
    this(x, y, 0);
  }
}

class MouseEvent {
  private MouseInteractable target;
  
  MouseEvent() {
  }
  
  public void setTarget(MouseInteractable target) {
    this.target = target;
  }
  
  public MouseInteractable getTarget() {
    return this.target;
  }
}

Реализация

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

abstract class GameObject implements Object {
  protected Position pos;
  protected Size size;
  
  protected State prevState;
  protected State state;
  
  GameObject(int x, int y, int width, int height) {
    this.pos = new Position(x, y);
    this.size = new Size(width, height);
    
    prevState = State.IDLE;
    state = State.IDLE;
  }
  
  public void setState(State state)
  {
    this.prevState = state;
    this.state = state;
  }
  
  public State getState()
  {
    return state;
  }
  
  public State getPrevState()
  {
    return prevState;
  }
  
  public Position getPosition()
  {
    return pos;
  }
  
  public Size getSize()
  {
    return size;
  }
  
  public void resize(int width, int height)
  {
    size.width = width;
    size.height = height;
  }
  
  public void move(int x, int y)
  {
    pos.x = x;
    pos.y = y;
  }
  
  public void move(int x, int y, int z)
  {
    pos.x = x;
    pos.y = y;
    pos.z = z;
  }
  
  abstract public void update();
  
  abstract public void draw();
}

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

enum Color {
    DEFAULT(#cccccc),
    HOVER(#888888);

    private int code;
    
    private Color(int code){
      this.code = code;
    }
    
    public int getCode() {
      return code;
    }
};

enum State{ IDLE, HOVER, DRAG };

class Square extends GameObject {
  private Color currentColor;
  
  Square(int x, int y, int width, int height) {
    super(x, y, width, height);
    
    currentColor = Color.DEFAULT;
  }
  
  public void update() {
    if (state == State.IDLE) {
      currentColor = Color.DEFAULT;
    } else {
      currentColor = Color.HOVER;
    }
  }
  
  public void draw() {
    strokeWeight(1);
    stroke(0x000000);
    fill(currentColor.getCode());
    rect(pos.x, pos.y, size.width, size.height, size.width / 4);
  }
}

Тестирование

Мы уже написали достаточно много кода, который было бы неплохо проверить, перед тем, как переходить к основному: реализации drag&drop. Для этого обновим наши функции draw() и setup() и запустим программу.

ArrayList<GameObject> gameObjects;
ArrayList<Drawable> drawables;
ArrayList<Updatable> updatables;

void setup() {
  size(1280, 720);
  pixelDensity(displayDensity());
  
  for (int i = 0; i < 5; i++) {
    GameObject box = new Square(10 + 60 * i, 10, 50, 50);
    gameObjects.add(box);
  }
  
  for (GameObject o : gameObjects) {
    drawables.add(o);
    updatables.add(o);
  }
}

void draw() {
  background(0xFFFFFF);
  for (Updatable o : updatables) {
    o.update();
  }

  for (Drawable o : drawables) {
    push();    // The push() function saves the current drawing style settings and transformations, while pop() restores these settings.
    o.draw();
    pop();
  }
}

Если вы все сделали правильно, после компиляции (на самом деле, это не совсем компиляция, но да ладно, опустим этот момент) вы должны увидеть окно размером 1280 пикселей на 720, в котором на белом фоне будут нарисованы пять наших квадратов.

Реализация drag&drop

Перед тем, как переходить к непосредственной реализации механизма drag&drop, расскажу, как я планирую это делать, попутно объясняя, почему я решил делать именно так. Как я уже писал ранее, этот пост выходит в рамках отчета по разработке match3 игры, из-за чего я стараюсь сделать свое решение максимально переиспользуемым. Поскольку в игре бывают сценарии, когда не все блоки могут перемещаться, я решил вынести логику взаимодействия с мышью в отдельный декоратор (как вы увидете далее, в два).

Для самых маленьких: декоратор — паттерн проектирования, который позволяет наделить объект каким-то специфичным поведением, не меняя при этом интерфейс самого объекта. Более подробно об этом паттерне и преимуществах/недостатках его использования можно прочитать здесь.

Кроме того, я не делал изменение порядка вывода элементов после перемещения одного из них. Т.е. после того, как вы переместите какой-то квадрат, так, что он будет пересекаться с другим, он может оказаться ниже (см. видео демонстрацию).

HoverableDecorator

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

class HoverableDecorator extends GameObject {  
  private GameObject decoratedObj;
  
  HoverableDecorator(GameObject obj) {
    super(0, 0, 0, 0);
    
    decoratedObj = obj;
    pos = decoratedObj.getPosition();
    size = decoratedObj.getSize();
  }
  
  public void update() {
    if (isMouseOver()) {
      decoratedObj.setState(State.HOVER);
    } else {
      decoratedObj.setState(State.IDLE);
    }
    
    decoratedObj.update();
  }
  
  public void draw() {
    decoratedObj.draw();
  }
  
  public boolean isMouseOver() {
    return mouseX > pos.x
      && mouseX <= pos.x + size.width
      && mouseY > pos.y
      && mouseY <= pos.y + size.height;
  }
}

HoverableDecorator расширяет абстрактный класс GameObject, чтобы сохранить его интерфейс и добавляет в метод update() новое поведение: при наведении мыши на объект, состояние последнего меняется на State.HOVER.

Для того, чтобы протестировать работу данного декоратора, необходимо немного изменить наш скетч. Заменим строку GameObject box = new Square(10 + 60 * i, 10, 50, 50); на GameObject box = new HoverableDecorator(new Square(10 + 60 * i, 10, 50, 50));  и запустим программу.

Теперь при наведении курсора мыши на квадрат последний меняет свой цвет, как показано выше.

DraggableDecorator

Наконец-то мы готовы к самому интересному: добавлению возможности двигать эти самые квадраты с помощью нашей чудесной мыши. Для того, чтобы наделить объекты способностью перемещаться, я создал еще один декоратор, который расширяет HoverableDecorator. Почему именно его, а не GameObject? Все просто: объект невозможно передвинуть, не наведя на него курсор мыши, поэтому субклассирование предыдущего декоратора вполне оправданно в данной ситуации. Кроме того, данный класс содержит метод, позволяющий определить, наведена ли мышь на объект или нет.

Однако мое решение немного противоречит концепции декораторов: мы можем обернуть в декоратор другой декоратор, чтобы расширить его поведение, но в моем случае помещение в DraggableDecorator объекта HoverableDecorator не даст особо смысла, кроме того, это может сломать логику, основанную на значении свойства state. Проблема со state решается реализацией методов onDragStart(), onDragEnd(), onMouseOver(), onMouseOut() в соответствующих декораторах.

class DraggableDecorator extends HoverableDecorator implements MouseInteractable {  
  private State previousState = State.IDLE;
  private State currentState = State.IDLE;
  
  private GameObject decoratedObj;
  private boolean pressed = false;
  
  private int z;
  
  DraggableDecorator(GameObject obj) {
    super(obj);
    
    decoratedObj = obj;
    pos = decoratedObj.getPosition();
    z = pos.z;
  }
  
  public void update() {
    if (isMouseOver() && !isDragged()) {
      changeState(State.HOVER);

      decoratedObj.move(pos.x, pos.y, z);
    } else if (isDragged()) {
      changeState(State.DRAG);
      
      decoratedObj.move(pos.x + (mouseX - pmouseX), pos.y + (mouseY - pmouseY), -1);
      pos = decoratedObj.pos;
    } else {
      changeState(State.IDLE);
      
      decoratedObj.move(pos.x, pos.y, z);
    }
    
    decoratedObj.setState(currentState);
    decoratedObj.update();
  }
  
  public void onMousePressed(MouseEvent event) {
    if ((event.getTarget() == this || event.getTarget() == null) && isMouseOver() && !pressed) {
      pressed = true;
    }
  }
  
  public void onMouseReleased(MouseEvent event) {
    if (pressed) {
      pressed = false;
    }
  }
  
  public void draw() {
    decoratedObj.draw();
  }
  
  public boolean isDragged() {
    return pressed
      && (previousState == State.HOVER || currentState == State.DRAG);
  }
  
  private void changeState(State newState) {
    previousState = currentState;
    currentState = newState;
  }
}

Вы можете заметить, что я сохраняю в оригинальное значение координаты z, когда объект находится в состоянии HOVER или IDLE. Это мой грязный хак, чтобы перемещаемый объект отображался поверх всех остальных, пока вы не отпустите кнопку мыши. Для корректного изменения позиции объекта я вычисляю разницу между текущей и предыдущей координатой мыши и прибавляю эту разницу к соответствующей координате объекта. А для того, чтобы всегда перемещался только один объект, я ввел логическую переменную pressed, которая меняется при нажатии и отпускании клавиши мыши на объекте. Если этого не сделать, то при перемещении объекта над другим последний тоже начнет перемещаться.

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

ArrayList<GameObject> gameObjects = new ArrayList<GameObject>();
ArrayList<Drawable> drawables = new ArrayList<Drawable>();
ArrayList<Updatable> updatables = new ArrayList<Updatable>();
ArrayList<MouseInteractable> mouseInteractables = new ArrayList<MouseInteractable>();

enum State{ IDLE, HOVER, DRAG };

enum Color {
    DEFAULT(#cccccc),
    HOVER(#888888);

    private int code;
    
    private Color(int code){
      this.code = code;
    }
    
    public int getCode() {
      return code;
    }
};

void setup() {
  size(1280, 720);
  pixelDensity(displayDensity());
  
  for (int i = 0; i < 5; i++) {
    GameObject box = new DraggableDecorator(new Square(10 + 60 * i, 10, 50, 50));
    gameObjects.add(box);
  }
  
  for (GameObject o : gameObjects) {
    drawables.add(o);
    updatables.add(o);
    
    if (o instanceof MouseInteractable) {
      mouseInteractables.add((MouseInteractable)o);
    }
  }
}

void draw() {
  background(0xFFFFFF);
  for (Updatable o : updatables) {
    o.update();
  }
  
  ArrayList<Movable> topElements = new ArrayList<Movable>();
  
  for (Drawable o : drawables) {
    if (o instanceof Movable && ((Movable)o).getPosition().z == -1) {
      topElements.add((Movable)o);
    }
    
    push();
    o.draw();
    pop();
  }
  
  for (Movable o : topElements) {
    push();
    ((Drawable)o).draw();
    pop();
  }
}

void mousePressed() {  
  MouseEvent event = new MouseEvent();
  MouseInteractable target = getMouseTarget();
  event.setTarget(target);
  
  for (MouseInteractable o : mouseInteractables) {
    o.onMousePressed(event);
  }
}

MouseInteractable getMouseTarget() {
  if (mouseInteractables.size() == 0) {
    return null;
  }
  
  MouseInteractable target = mouseInteractables.get(0);
  
  for (MouseInteractable o : mouseInteractables) {
    if (o.isMouseOver() && o.getPosition().z >= target.getPosition().z) {
      target = o;
    }
  }
  
  return target;
}

void mouseReleased() {
  MouseEvent event = new MouseEvent();
  
  for (MouseInteractable o : mouseInteractables) {
    o.onMouseReleased(event);
  }
}

Функции mousePressed() и mouseReleased() являются встроенными, как setup() или draw(). Для чего они нужны, думаю, понятно из названия. В mousePressed() я создаю экземпляр класса MouseEvent, в который помещаю объект, на который кликнул пользователь (если такой объект есть), а после вызываю метод onMousePressed() на всех объектах, реализующих интерфейс MouseInteractable. При отпускании кнопки мыши я просто создаю пустое событие, которое передаю в метод onMouseReleased() для каждого объекта, реализующего интерфейс MouseInteractable.

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

Заключение

Как мы видим, наши объекты успешно перемещаются, а это значит, что мы правильно реализовали логику. Думаю, на этом можно закончить данный туториал. Конечно, в моем коде остались некоторые проблемы, упомянутые ранее, однако их решение уже выходит за рамки данного поста. Давайте считать, что это ваше домашнее задание. Ниже я привел ссылки на материалы, которые могут оказаться вам полезными. Там же я оставил ссылку на GitHub репозиторий с исходным кодом.

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

Полезные ссылки

• Полный код данного урока: https://github.com/mazanax/processing-drag-n-drop

• Демонстрация работы: https://www.youtube.com/watch?v=KcTzZLQDlEc

• Среда Processing: https://processing.org/

• Паттерны проектирования: https://refactoring.guru/ru/design-patterns/

• Что такое принципы SOLID: https://ru.wikipedia.org/wiki/SOLID_(объектно-ориентированное_программирование)