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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    controls;
    text2;
    text;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('eye', 'assets/pics/lance-overdose-loader-eye.png');
    }

    create ()
    {
        for (let i = 0; i < 32; i++)
        {
            const x = Phaser.Math.Between(0, 2000);
            const y = Phaser.Math.Between(0, 2000);

            this.add.sprite(x, y, 'eye').setInteractive();
        }

        this.input.on('gameobjectover', (pointer, gameObject) =>
        {

            gameObject.setTint(0xff0000);

        });

        this.input.on('gameobjectout', (pointer, gameObject) =>
        {

            gameObject.clearTint();

        });

        const cursors = this.input.keyboard.createCursorKeys();

        const controlConfig = {
            camera: this.cameras.main,
            left: cursors.left,
            right: cursors.right,
            up: cursors.up,
            down: cursors.down,
            zoomIn: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Q),
            zoomOut: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
            acceleration: 0.06,
            drag: 0.0005,
            maxSpeed: 1.0
        };

        this.controls = new Phaser.Cameras.Controls.SmoothedKeyControl(controlConfig);

        // this.cameras.main.setBackgroundColor('rgba(255, 0, 0, 0.5)');
        // this.cameras.main.setZoom(0.8);
        // this.cameras.main.setRotation(Phaser.Math.DegToRad(10));

        this.text = this.add.text(100, 200, 'x: 0 y: 0', { font: '18px Courier', fill: '#00ff00' }).setScrollFactor(0);
        this.text2 = this.add.text(100, 400, '', { font: '18px Courier', fill: '#00ff00' }).setScrollFactor(0);

        this.input.keyboard.on('keydown-Z', function (event)
        {

            this.cameras.main.setRotation(this.cameras.main.rotation + 0.01);

        }, this);

        this.input.keyboard.on('keydown-X', function (event)
        {

            this.cameras.main.setRotation(this.cameras.main.rotation - 0.01);

        }, this);

        this.input.once('pointerdown', function ()
        {
            this.scale.startFullscreen();
        }, this);
    }

    update (time, delta)
    {
        this.controls.update(delta);

        const cam = this.cameras.main;

        //  Take a coordinate from screen space and convert it into World space within the Camera
        // var p = cam.screenToCamera({ x: this.input.x, y: this.input.y });

        const p = this.input.activePointer.positionToCamera(cam);

        this.text.setText([
            `cx: ${cam.scrollX}`,
            `cy: ${cam.scrollY}`,
            '',
            `sx: ${this.input.x}`,
            `sy: ${this.input.y}`,
            '',
            `px: ${p.x}`,
            `py: ${p.y}`
        ]);

        this.text2.setText([
            `a: ${cam.matrix.matrix[0]}`,
            `b: ${cam.matrix.matrix[1]}`,
            `c: ${cam.matrix.matrix[2]}`,
            `d: ${cam.matrix.matrix[3]}`,
            `tx: ${cam.matrix.matrix[4]}`,
            `ty: ${cam.matrix.matrix[5]}`
        ]);
    }
}

const config = {
    type: Phaser.WEBGL,
    backgroundColor: '#2dab2d',
    scale: {
        mode: Phaser.Scale.FIT,
        parent: 'phaser-example',
        width: 800,
        height: 600
    },
    scene: Example
};

const game = new Phaser.Game(config);

Подготовка сцены: создание интерактивного мира

В методе preload загружается одно изображение, которое затем используется для создания 32 спрайтов в случайных координатах внутри большого игрового мира (2000x2000 пикселей). Каждому спрайту сразу назначается интерактивность с помощью метода setInteractive(), что позволяет ему реагировать на события ввода.

for (let i = 0; i < 32; i++)
{
    const x = Phaser.Math.Between(0, 2000);
    const y = Phaser.Math.Between(0, 2000);
    this.add.sprite(x, y, 'eye').setInteractive();
}

Далее настраиваются обработчики событий gameobjectover и gameobjectout для всего ввода сцены (this.input). Когда курсор наводится на любой интерактивный игровой объект, он окрашивается в красный цвет, а когда уходит — окраска сбрасывается.

this.input.on('gameobjectover', (pointer, gameObject) => {
    gameObject.setTint(0xff0000);
});

this.input.on('gameobjectout', (pointer, gameObject) => {
    gameObject.clearTint();
});

Плавное управление камерой с клавиатуры

Одной из ключевых особенностей примера является использование Phaser.Cameras.Controls.SmoothedKeyControl. Этот контроллер обеспечивает плавное, инерционное движение и зум камеры. Сначала создаётся объект конфигурации, который связывает клавиши управления с основной камерой (this.cameras.main).

const controlConfig = {
    camera: this.cameras.main,
    left: cursors.left,
    right: cursors.right,
    up: cursors.up,
    down: cursors.down,
    zoomIn: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Q),
    zoomOut: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
    acceleration: 0.06,
    drag: 0.0005,
    maxSpeed: 1.0
};

this.controls = new Phaser.Cameras.Controls.SmoothedKeyControl(controlConfig);

Параметры acceleration, drag и maxSpeed отвечают за разгон, замедление и максимальную скорость перемещения камеры. Для работы инерционного движения необходимо каждый кадр вызывать метод update(delta) этого контроллера, что делается в методе update сцены.

update (time, delta)
{
    this.controls.update(delta);
    // ... остальной код
}

Дополнительно назначаются обработчики на клавиши Z и X для вращения камеры вокруг её точки обзора, изменяя свойство rotation.

this.input.keyboard.on('keydown-Z', function (event) {
    this.cameras.main.setRotation(this.cameras.main.rotation + 0.01);
}, this);

Преобразование координат: от экрана к миру

Самая важная часть для понимания — это разница между координатами на экране (области просмотра) и координатами в игровом мире. При скролле, зуме или повороте камеры эти системы координат не совпадают.

В примере создаются два текстовых поля с setScrollFactor(0), чтобы они оставались на месте экрана, а не двигались с камерой. В них выводится диагностическая информация.

Ключевая строка кода использует метод positionToCamera() активного указателя (курсора мыши или касания). Этот метод преобразует текущие экранные координаты (this.input.x, this.input.y) в координаты внутри пространства, которое видит конкретная камера (в данном случае — основная).

const p = this.input.activePointer.positionToCamera(cam);

Результат `p— это объект с координатамиxиy, которые показывают, куда в *мировых координатах* камеры сейчас указывает курсор. Эти координаты, наряду с позицией скролла камеры (cam.scrollX,cam.scrollY`) и сырыми экранными координатами, выводятся в первое текстовое поле.

this.text.setText([
    `cx: ${cam.scrollX}`,
    `cy: ${cam.scrollY}`,
    '',
    `sx: ${this.input.x}`,
    `sy: ${this.input.y}`,
    '',
    `px: ${p.x}`,
    `py: ${p.y}`
]);

За кулисами: матрица трансформации камеры

Все преобразования камеры (смещение, масштабирование, поворот) в конечном итоге описываются матрицей 3x2. Второе текстовое поле в примере выводит значения этой матрицы, предоставляя низкоуровневое представление о том, как камера трансформирует вид.

this.text2.setText([
    `a: ${cam.matrix.matrix[0]}`,
    `b: ${cam.matrix.matrix[1]}`,
    `c: ${cam.matrix.matrix[2]}`,
    `d: ${cam.matrix.matrix[3]}`,
    `tx: ${cam.matrix.matrix[4]}`,
    `ty: ${cam.matrix.matrix[5]}`
]);

Элементы `a,b,c,dотвечают за масштаб и поворот, аtxиty— за смещение (скролл). МетодpositionToCamera()` внутри себя использует именно эту матрицу для обратного преобразования экранной точки в мировую. Знакомство с этими значениями полезно для отладки сложных визуальных эффектов или создания собственных систем преобразования координат.

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

Этот пример комплексно демонстрирует две важные механики: интуитивное управление камерой с инерцией и точное преобразование координат ввода. Метод activePointer.positionToCamera() — ваш главный инструмент для взаимодействия с игровым миром при любых трансформациях камеры. Для экспериментов попробуйте изменить параметры acceleration и drag у контроллера камеры, чтобы добиться иного "ощущения" управления. Или используйте полученные мировые координаты p.x и p.y для создания объектов (например, выстрелов) именно в точке, куда указывает игрок, даже если мир сильно увеличен или повёрнут.