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

При разработке игр с изометрическими или обычными тайловыми картами часто возникает задача взаимодействия с областью: подсветка зоны действия заклинания, выбор юнитов в радиусе или определение тайлов под курсором сложной формы. Метод `getTilesWithinShape` из API Phaser 3 Tilemap Layers позволяет решить эту задачу элегантно, без необходимости вручную перебирать все тайлы на карте. В этой статье разберем, как работает этот метод на практическом примере с динамической областью поиска, управляемой мышью.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    controls;

    preload ()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('tiles', 'assets/tilemaps/iso/iso-64x64-outline.png');
        this.load.image('tiles2', 'assets/tilemaps/iso/iso-64x64-building.png');
        this.load.tilemapTiledJSON('map', 'assets/tilemaps/iso/isorpg.json');
    }

    create ()
    {
        this.overlappingTiles = [];
        this.map = this.add.tilemap('map');

        const tileset1 = this.map.addTilesetImage('iso-64x64-outside', 'tiles');
        const tileset2 = this.map.addTilesetImage('iso-64x64-building', 'tiles2');

        this.layer1 = this.map.createLayer('Tile Layer 1', [ tileset1, tileset2 ]);

        this.graphics = this.add.graphics({ lineStyle: { width: 2, color: 0x00ff00 } });

        this.radius = 64;
        this.circle = new Phaser.Geom.Circle(400, 300, this.radius);
        this.rectangle = new Phaser.Geom.Rectangle(0, 0, this.radius * 2, this.radius * 2);
        const cursors = this.input.keyboard.createCursorKeys();

        // this.layer1.setScale(2, 2);
        // this.cameras.main.setZoom(2);

        const controlConfig = {
            camera: this.cameras.main,
            left: cursors.left,
            right: cursors.right,
            up: cursors.up,
            down: cursors.down,
            acceleration: 0.04,
            drag: 0.0005,
            maxSpeed: 0.7
        };

        this.controls = new Phaser.Cameras.Controls.SmoothedKeyControl(controlConfig);
        this.input.on('pointermove', this.updateCircle, this);

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

    updateCircle ()
    {
        const point = this.input.activePointer.positionToCamera(this.cameras.main);

        this.overlappingTiles.forEach((tile) => { tile.tint = 0xffffff; });
        this.overlappingTiles = [];

        this.circle.setPosition(point.x, point.y);
        this.rectangle.setPosition(point.x - this.radius, point.y - this.radius);
        this.graphics.clear();
        this.graphics.strokeCircleShape(this.circle);
        this.graphics.strokeRectShape(this.rectangle);

        var tiles = this.layer1.getTilesWithinShape(this.circle);
        // var tiles = this.layer1.getTilesWithinShape(this.rectangle);
        tiles.forEach(tile =>
        {
            this.overlappingTiles.push(tile);
        });

        this.overlappingTiles.forEach((tile) => { tile.tint = 0xff0000; });
    }

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

        const worldPoint = this.input.activePointer.positionToCamera(this.cameras.main);

        const message = [ `Mouse Position: ${worldPoint.x}, ${worldPoint.y}` ];

        const tile = this.map.getTileAtWorldXY(worldPoint.x, worldPoint.y);
        if (tile)
        {
            message.push(`Tile Center Position: ${tile.getCenterX()}, ${tile.getCenterY()}`);
            message.push(`Tile Bounds: ${tile.getLeft()}, ${tile.getTop()} -> ${tile.getRight()}, ${tile.getBottom()}`);
        }

        this.text.setText(message);

        // this.updateCircle(worldPoint);
    }
}

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

const game = new Phaser.Game(config);

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

Пример начинается с создания сцены, которая загружает тайловую карту и два набора тайлов (tilesets). Карта загружается из JSON-файла, созданного в редакторе Tiled.

preload ()
{
    this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
    this.load.image('tiles', 'assets/tilemaps/iso/iso-64x64-outline.png');
    this.load.image('tiles2', 'assets/tilemaps/iso/iso-64x64-building.png');
    this.load.tilemapTiledJSON('map', 'assets/tilemaps/iso/isorpg.json');
}

В методе create создается экземпляр тайловой карты (this.map), к нему добавляются tilesets, и на их основе формируется слой (this.layer1). Этот слой и будет основным объектом для поиска тайлов.

create ()
{
    this.overlappingTiles = [];
    this.map = this.add.tilemap('map');
    const tileset1 = this.map.addTilesetImage('iso-64x64-outline', 'tiles');
    const tileset2 = this.map.addTilesetImage('iso-64x64-building', 'tiles2');
    this.layer1 = this.map.createLayer('Tile Layer 1', [ tileset1, tileset2 ]);
}

Создание геометрических фигур и элементов управления

Для визуализации области поиска и взаимодействия создаются две геометрические фигуры: круг (Phaser.Geom.Circle) и прямоугольник (Phaser.Geom.Rectangle). Изначально используется круг. Также настраивается управление камерой с клавиатуры для навигации по большой карте и графический объект (this.graphics) для отрисовки контура фигуры.

this.graphics = this.add.graphics({ lineStyle: { width: 2, color: 0x00ff00 } });
this.radius = 64;
this.circle = new Phaser.Geom.Circle(400, 300, this.radius);
this.rectangle = new Phaser.Geom.Rectangle(0, 0, this.radius * 2, this.radius * 2);
const cursors = this.input.keyboard.createCursorKeys();

const controlConfig = {
    camera: this.cameras.main,
    left: cursors.left,
    right: cursors.right,
    up: cursors.up,
    down: cursors.down,
    acceleration: 0.04,
    drag: 0.0005,
    maxSpeed: 0.7
};
this.controls = new Phaser.Cameras.Controls.SmoothedKeyControl(controlConfig);

Событие pointermove привязано к методу updateCircle, который будет пересчитывать позицию фигуры и искать тайлы.

Ядро логики: поиск тайлов в области

Ключевой метод всего примера — this.layer1.getTilesWithinShape(this.circle). Он принимает в качестве аргумента объект геометрической фигуры (круг, прямоугольник, треугольник и т.д.) и возвращает массив тайлов, чьи границы пересекаются с этой фигурой.

Логика в updateCircle: 1. Сбрасывается подсветка ранее найденных тайлов. 2. Позиция круга обновляется в соответствии с положением курсора в мировых координатах (с учетом камеры). 3. Контур фигуры перерисовывается. 4. Вызывается getTilesWithinShape для получения нового списка тайлов. 5. Найденные тайлы подсвечиваются красным оттенком (tint).

updateCircle ()
{
    const point = this.input.activePointer.positionToCamera(this.cameras.main);
    this.overlappingTiles.forEach((tile) => { tile.tint = 0xffffff; });
    this.overlappingTiles = [];

    this.circle.setPosition(point.x, point.y);
    this.graphics.clear();
    this.graphics.strokeCircleShape(this.circle);

    var tiles = this.layer1.getTilesWithinShape(this.circle);
    tiles.forEach(tile =>
    {
        this.overlappingTiles.push(tile);
    });
    this.overlappingTiles.forEach((tile) => { tile.tint = 0xff0000; });
}

**Важно:** метод getTilesWithinShape работает с *мировыми координатами* фигуры. Поэтому позиция круга задается через point.x, point.y, которые уже переведены из координат камеры в мировые с помощью positionToCamera.

Отладка и отображение информации

В методе update реализован дополнительный инструмент для отладки. Он постоянно определяет тайл под точкой курсора (используя this.map.getTileAtWorldXY) и выводит в текстовое поле его координаты и границы.

update (time, delta)
{
    this.controls.update(delta);
    const worldPoint = this.input.activePointer.positionToCamera(this.cameras.main);
    const message = [ `Mouse Position: ${worldPoint.x}, ${worldPoint.y}` ];

    const tile = this.map.getTileAtWorldXY(worldPoint.x, worldPoint.y);
    if (tile)
    {
        message.push(`Tile Center Position: ${tile.getCenterX()}, ${tile.getCenterY()}`);
        message.push(`Tile Bounds: ${tile.getLeft()}, ${tile.getTop()} -> ${tile.getRight()}, ${tile.getBottom()}`);
    }
    this.text.setText(message);
}

Это позволяет наглядно сравнить результат точечного поиска (getTileAtWorldXY) и поиска по области (getTilesWithinShape). Текстовый элемент зафиксирован на экране (setScrollFactor(0)), чтобы его было видно при движении камеры.

Гибкость использования: от круга к прямоугольнику

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

// var tiles = this.layer1.getTilesWithinShape(this.circle);
var tiles = this.layer1.getTilesWithinShape(this.rectangle);

Это показывает, что метод универсален и может работать с любым объектом, наследующимся от Phaser.Geom.Shape. Также в коде заданы базовые размеры прямоугольника, но его позиция динамически обновляется вместе с курсором.

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

Метод getTilesWithinShape — мощный и производительный инструмент для работы с тайловыми областями в Phaser 3. Он избавляет разработчика от необходимости писать сложные алгоритмы пересечения и работает напрямую с оптимизированными структурами данных тайловой карты. **Идеи для экспериментов:** 1. Используйте другие фигуры, например, Phaser.Geom.Triangle или Phaser.Geom.Ellipse. 2. Реализуйте выбор юнитов в радиусе, комбинируя поиск тайлов с проверкой объектов, находящихся на этих тайлах. 3. Измените логику подсветки: например, окрашивайте тайлы в разные цвета в зависимости от их свойства (проходимость, тип местности). 4. Создайте фигуру произвольной формы с помощью Phaser.Geom.Polygon и используйте её для поиска.