О чем этот пример

Реализация перетаскивания объектов — одна из базовых механик в играх и интерактивных приложениях. Однако, когда объект находится внутри контейнера, с координатами может происходить путаница, и drag&drop работает некорректно. В этой статье мы разберем пример из официального репозитория Phaser, который демонстрирует проблему с обработкой координат при перетаскивании объектов внутри `Container`. Вы поймете, почему простой вызов `setPosition(x, y)` в обработчике события `GAMEOBJECT_DRAG_END` не дает ожидаемого результата, и как правильно работать с локальными и мировыми координатами в таких сценариях.

Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.

Живой запуск

Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.

Исходный код


class Example extends Phaser.Scene
{
    preload ()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('bg', 'assets/skies/gradient13.png');
        this.load.atlas('rocket', 'assets/animations/rocket.png', 'assets/animations/rocket.json');
    }

    create ()
    {
        // const r0 = this.add.rectangle(this.scale.width * .75, this.scale.height * 0.75, 100, 100, 0xff0000).setInteractive({ draggable: true })
        //     .on(Phaser.Input.Events.GAMEOBJECT_DRAG, (pointer, x, y) =>
        //     {
        //         r0.setPosition(x, y);
        //         console.log(x, y);
        //     })

        //     .on(Phaser.Input.Events.GAMEOBJECT_DRAG_END, (pointer, x, y) =>
        //     {
        //         r0.setPosition(x, y); // this does not work as expected
        //         console.log(x, y);

        //         const localX = r0.x + x;
        //         const localY = r0.y + y;
        //         console.log(r0.x, r0.y, x, y);
        //         r0.setPosition(localX, localY); // this does not work either
        //     });

        // const r1 = this.add.rectangle(this.scale.width * .75 + 150, this.scale.height * 0.75, 100, 100, 0xff0000).setInteractive({ draggable: true })
        //     .on(Phaser.Input.Events.GAMEOBJECT_DRAG, (pointer, x, y) =>
        //     {
        //         r1.setPosition(x, y);
        //         console.log(x, y);
        //     })
        //     .on(Phaser.Input.Events.GAMEOBJECT_DRAG_END, (pointer, x, y) =>
        //     {
        //         r1.setPosition(x, y); // this does not work as expected
        //         console.log(x, y);

        //         const localX = r1.x + x;
        //         const localY = r1.y + y;
        //         console.log(localX, localY);
        //         r1.setPosition(localX, localY); // this does not work either
        //     });

        const c = this.add.container(250, 250);
        const r2 = this.add.rectangle(0, 0, 100, 100, 0xff0000).setInteractive({ draggable: true })
            .on(Phaser.Input.Events.GAMEOBJECT_DRAG, (pointer, x, y) =>
            {
                r2.setPosition(x, y);
                console.log(x, y);
            })
            .on(Phaser.Input.Events.GAMEOBJECT_DRAG_END, (pointer, x, y) =>
            {
                // r2.setPosition(x, y); // this does not work as expected
                // console.log(x, y);

                const localX = r2.x + x;
                const localY = r2.y + y;
                console.log(localX, localY);
                r2.setPosition(localX, localY); // this does not work either
            });

        const r3 = this.add.rectangle(150, 0, 100, 100, 0xff0000).setInteractive({ draggable: true })
            .on(Phaser.Input.Events.GAMEOBJECT_DRAG, (pointer, x, y) =>
            {
                r3.setPosition(x, y);
                console.log(x, y);
            })
            .on(Phaser.Input.Events.GAMEOBJECT_DRAG_END, (pointer, x, y) =>
            {
                // r3.setPosition(x, y); // this does not work as expected
                // console.log(x, y);

                const localX = r3.x + x;
                const localY = r3.y + y;
                console.log(localX, localY);
                r3.setPosition(localX, localY); // this does not work either
            });

        c.add([ r2, r3 ]);
    }

    createText ()
    {
        this.text = this.add.text(16, 16, '', {
            font: '16px Arial',
            backgroundColor: '#000000',
            fill: '#ffffff'
        });
    }

    updateText (x, y)
    {
        const result = {
            x: this.rocket.x + x,
            y: this.rocket.y + y,
        }
        const message = [ `Rocket: ${this.rocket.x}, ${this.rocket.y}` ];
        message.push(`Display Origin: ${this.rocket.displayOriginX}, ${this.rocket.displayOriginY}`);
        message.push(`Mouse Position: ${this.rocket.input.localX}, ${this.rocket.input.localY}`);
        message.push(`Expected RESULT: ${result.x}, ${result.y}`);

        this.text.setText(message);
    }

    // update (time, delta)
    // {
    //     const worldPoint = this.input.activePointer.positionToCamera(this.cameras.main);

    //     const message = [ `Mouse Position: ${this.rocket.input.localX}, ${this.rocket.input.localY}` ];

    //     this.text.setText(message);

    //     // this.updateCircle(worldPoint);
    // }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#2d2d2d',
    parent: 'phaser-example',
    pixelArt: true,
    scene: Example
};

const game = new Phaser.Game(config);

Суть проблемы

В исходном примере разработчик столкнулся с неожиданным поведением при попытке зафиксировать позицию игрового объекта после окончания перетаскивания (событие GAMEOBJECT_DRAG_END).

Прямое присвоение координат из события (setPosition(x, y)) не работает, как и попытка сложить текущие координаты объекта с координатами события (r2.x + x, r2.y + y).

Основная причина в том, что координаты (x, y), передаваемые в событиях GAMEOBJECT_DRAG и GAMEOBJECT_DRAG_END, являются **мировыми координатами** (координатами указателя мыши в игровом мире). Однако, когда объект находится внутри контейнера (Container), его система координат становится локальной относительно этого контейнера. Метод setPosition() для дочернего объекта контейнера ожидает **локальные координаты**.

Простое присвоение мировых координат локальной системе приводит объект в неверную позицию относительно родителя.

Разбираем код примера

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

const c = this.add.container(250, 250);
const r2 = this.add.rectangle(0, 0, 100, 100, 0xff0000).setInteractive({ draggable: true })
    .on(Phaser.Input.Events.GAMEOBJECT_DRAG, (pointer, x, y) =>
    {
        r2.setPosition(x, y);
        console.log(x, y);
    })
    .on(Phaser.Input.Events.GAMEOBJECT_DRAG_END, (pointer, x, y) =>
    {
        const localX = r2.x + x;
        const localY = r2.y + y;
        console.log(localX, localY);
        r2.setPosition(localX, localY);
    });

Здесь r2 создается с локальными координатами (0, 0) и добавляется в контейнер `c. В обработчикеDRAGобъекту присваиваются мировые координаты указателя, что заставляет его "прыгать" в мировую позицию мыши, игнорируя контейнер. В обработчикеDRAG_END` разработчик пытается исправить это, складывая текущую локальную позицию объекта с мировой позицией мыши, что является некорректной операцией между двумя разными системами координат.

Решение: преобразование мировых координат в локальные

Для корректной работы необходимо преобразовать мировые координаты, полученные в событии, в локальные координаты относительно родительского контейнера. В Phaser для этого у класса Container есть метод pointToContainer().

Давайте перепишем обработчик события GAMEOBJECT_DRAG:

.on(Phaser.Input.Events.GAMEOBJECT_DRAG, (pointer, dragX, dragY) =>
{
    // Преобразуем мировые координаты dragX/dragY в локальные относительно контейнера 'c'
    const localPoint = c.pointToContainer(dragX, dragY);
    // Устанавливаем прямоугольнику преобразованные локальные координаты
    r2.setPosition(localPoint.x, localPoint.y);
})

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

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

.on(Phaser.Input.Events.GAMEOBJECT_DRAG_END, (pointer, dragX, dragY) =>
{
    const localPoint = c.pointToContainer(dragX, dragY);
    r2.setPosition(localPoint.x, localPoint.y);
});

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

Альтернатива: использование pointer в событии DRAG

Обратите внимание, что в события перетаскивания также передается сам объект pointer. У него есть свойство worldX / worldY, но для нашей задачи они эквивалентны параметрам `xиy. Однако, если вам нужны дополнительные данные о состоянии ввода, доступ кpointer` может быть полезен.

Также стоит помнить о методе setInteractive(). В примере используется опция { draggable: true }, которая и включает механизм перетаскивания, генерирующий события GAMEOBJECT_DRAG.

.setInteractive({ draggable: true })

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

Что попробовать дальше

Ключевой вывод: при работе с перетаскиванием объектов внутри контейнеров в Phaser всегда учитывайте разницу между мировыми и локальными системами координат. Используйте метод контейнера pointToContainer() для корректного преобразования позиции указателя. Для экспериментов попробуйте: 1. Создать более сложную иерархию с вложенными контейнерами и реализовать перетаскивание объектов на разных уровнях. 2. Ограничить область перетаскивания (dragDistance, dropZone) и применить преобразование координат для этих ограничений. 3. Реализовать "привязку" (snap) объекта к сетке после перетаскивания, выполняя расчеты в локальных координатах контейнера.