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

При разработке игр с большими картами и интерфейсом часто возникает задача: как отображать UI-элементы (например, подсказки или маркеры), которые должны быть привязаны к объектам в игровом мире, но при этом корректно работать с движущейся и зумирующей камерой? Обычный `add.text` или `add.image` в координатах мира будет уезжать вместе с камерой. В этой статье разберем продвинутую технику из официального примера Phaser, которая решает эту проблему с помощью двух сцен и математики преобразования координат. Этот подход полезен для создания карт, тактических интерфейсов или любых интерактивных элементов, "парящих" над динамическим игровым миром.

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

Живой запуск

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

Исходный код


class MapScene extends Phaser.Scene {

    constructor ()
    {
        super('MapScene');

        //  Sphinx
        this.location1 = new Phaser.Math.Vector2(766, 1090);

        //  Oasis
        this.location2 = new Phaser.Math.Vector2(225, 1552);

        //  Tomb
        this.location3 = new Phaser.Math.Vector2(700, 1592);

        //  City Gates
        this.location4 = new Phaser.Math.Vector2(323, 480);

        //  Chair
        this.location5 = new Phaser.Math.Vector2(593, 274);

        //  River Hormuz
        this.location6 = new Phaser.Math.Vector2(180, 1087);

        //  Guard Outpost
        this.location7 = new Phaser.Math.Vector2(168, 163);
    }

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('map', 'assets/tests/camera/earthbound-scarab.png');
    }

    create ()
    {
        this.cameras.main.setBounds(0, 0, 1024, 2048);

        this.add.image(0, 0, 'map').setOrigin(0);

        this.cameras.main.setZoom(1);
        this.cameras.main.centerOn(0, 0);

        var pos = 1;

        this.input.on('pointerdown', function () {

            var cam = this.cameras.main;
            var location = this['location' + pos];
            var rndZoom = Phaser.Math.FloatBetween(0.5, 4);

            cam.pan(location.x, location.y, 3000, 'Sine.easeInOut');
            cam.zoomTo(rndZoom, 3000);

            pos++;

            if (pos === 8)
            {
                pos = 1;
            }


        }, this);

        this.scene.launch('UIScene');
    }

}

class UIScene extends Phaser.Scene {

    constructor ()
    {
        super('UIScene');

        this.mapScene;
        this.mapCamera;

        this.graphics;

        this.tooltip1;
        this.tooltip2;
        this.tooltip3;
        this.tooltip4;
        this.tooltip5;
        this.tooltip6;
        this.tooltip7;
    }

    create ()
    {
        this.mapScene = this.scene.get('MapScene');

        this.mapCamera = this.mapScene.cameras.main;

        this.graphics = this.add.graphics();

        this.tooltip1 = this.add.text(0, 0).setText('Sphinx');
        this.tooltip2 = this.add.text(0, 0).setText('Oasis');
        this.tooltip3 = this.add.text(0, 0).setText('Tomb of Ket');
        this.tooltip4 = this.add.text(0, 0).setText('City Gates');
        this.tooltip5 = this.add.text(0, 0).setText('Rest Easy');
        this.tooltip6 = this.add.text(0, 0).setText('River Hormuz');
        this.tooltip7 = this.add.text(0, 0).setText('Guard Outpost');
    }

    update ()
    {
        this.graphics.clear();

        this.updateToolTip(this.mapScene.location1, this.tooltip1);
        this.updateToolTip(this.mapScene.location2, this.tooltip2);
        this.updateToolTip(this.mapScene.location3, this.tooltip3);
        this.updateToolTip(this.mapScene.location4, this.tooltip4);
        this.updateToolTip(this.mapScene.location5, this.tooltip5);
        this.updateToolTip(this.mapScene.location6, this.tooltip6);
        this.updateToolTip(this.mapScene.location7, this.tooltip7);
    }

    updateToolTip (source, tooltip)
    {
        var basePosition = source;
        var camera = this.mapCamera;

        //  The marker point
        var x = (basePosition.x - camera.worldView.x) * camera.zoom;
        var y = (basePosition.y - camera.worldView.y) * camera.zoom;

        var graphics = this.graphics;

        graphics.fillStyle(0x000000, 0.8);
        graphics.lineStyle(4, 0x000000, 0.8);

        //  The text is above this point
        var width = tooltip.width + 32;
        var height = tooltip.height + 32;

        var bx = x - width / 2;
        var by = y - (height + 32);

        graphics.fillRect(bx, by, width, height);

        tooltip.x = bx + 16;
        tooltip.y = by + 16;

        graphics.lineBetween(bx + 16, by + height, x, y);
    }

}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    pixelArt: true,
    parent: 'phaser-example',
    scene: [ MapScene, UIScene ]
};

const game = new Phaser.Game(config);

Архитектура: две сцены, одна цель

Ключевая идея — разделить логику. Одна сцена (MapScene) отвечает за игровой мир, его отрисовку и управление камерой. Вторая сцена (UIScene) отвечает за отображение интерфейса поверх всего. Они запускаются параллельно. UIScene не имеет собственной камеры для мира, вместо этого она получает ссылку на камеру из MapScene и использует её параметры для вычисления позиции UI-элементов на экране.

create () {
    this.mapScene = this.scene.get('MapScene');
    this.mapCamera = this.mapScene.cameras.main;
}

Магия преобразования мировых координат в экранные

Сердце решения — метод updateToolTip. Он получает мировые координаты точки интереса (например, координаты сфинкса на карте) и объект текста. Задача: вычислить, где на экране (в координатах канваса) должна находиться подсказка, учитывая текущую позицию камеры (worldView) и её масштаб (zoom).

var x = (basePosition.x - camera.worldView.x) * camera.zoom;
var y = (basePosition.y - camera.worldView.y) * camera.zoom;

Формула проста: из мировых координат объекта вычитаем координаты левого верхнего угла "окна" камеры в мире. Получаем смещение объекта относительно этого угла. Затем умножаем на zoom, чтобы это смещение соответствовало текущему масштабу отображения. Результат (`x,y`) — это уже экранные координаты, где находится объект. Именно над этой точкой мы и будем рисовать нашу подсказку.

Отрисовка и позиционирование элементов UI

Получив точку на экране, мы можем разместить около неё графику и текст. В примере для каждой подсказки рисуется закругленный прямоугольник и соединительная линия до маркера. Важно, что все эти элементы (graphics, tooltip) создаются и управляются в UIScene и не зависят от перемещений камеры в MapScene. Позиция текста вычисляется относительно рассчитанных экранных координат.

tooltip.x = bx + 16;
tooltip.y = by + 16;
graphics.lineBetween(bx + 16, by + height, x, y);

Поскольку метод update в UIScene вызывается каждый кадр, все подсказки постоянно "преследуют" свои цели на экране, создавая эффект статичного, но контекстного интерфейса.

Как это работает в действии: панорамирование и зум

В MapScene по клику мыши камера плавно панорамирует к одной из заранее заданных точек и изменяет масштаб. Код использует методы cam.pan() и cam.zoomTo().

cam.pan(location.x, location.y, 3000, 'Sine.easeInOut');
cam.zoomTo(rndZoom, 3000);

Во время этих анимаций camera.worldView и camera.zoom непрерывно меняются. Благодаря тому, что UIScene в своём update() пересчитывает позиции, используя актуальные значения этих свойств, подсказки плавно следуют за движущимися метками, оставаясь строго над ними на экране, независимо от зума.

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

Использование двух сцен — мощный паттерн в Phaser для разделения игровой логики и интерфейса. Показанный подход с преобразованием координат через worldView и zoom решает классическую проблему "привязки UI к миру". Для экспериментов попробуйте: добавить кликабельность самим подсказкам, реализовать их плавное появление при приближении камеры, или использовать этот метод для отображения интерактивных маркеров квеста над головами NPC в RPG-игре.