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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    helpText;
    onlyColliding = false;
    selectedShape = 'rectangle';
    graphics;
    controls;
    map;
    p2 = null;
    p1 = null;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.tilemapTiledJSON('map', 'assets/tilemaps/maps/cybernoid.json');
        this.load.image('cybernoid', 'assets/tilemaps/tiles/cybernoid.png');
    }

    create ()
    {
        this.map = this.add.tilemap('map');
        const tiles = this.map.addTilesetImage('cybernoid');
        const layer = this.map.createLayer(0, tiles);

        // var layer = map.createDynamicLayer(0, tiles);

        layer.setScale(1.25, 1.25);

        this.graphics = this.add.graphics({
            lineStyle: { width: 4, color: 0xa8fff2 },
            fillStyle: { color: 0xa8fff2 }
        });

        this.map.setCollisionByExclusion(7);

        this.input.keyboard.on('keydown-ONE', (event) =>
        {
            this.selectedShape = 'rectangle';
            this.helpText.setText(this.getHelpMessage());
        });

        this.input.keyboard.on('keydown-TWO', (event) =>
        {
            this.selectedShape = 'line';
            this.helpText.setText(this.getHelpMessage());
        });

        this.input.keyboard.on('keydown-THREE', (event) =>
        {
            this.selectedShape = 'circle';
            this.helpText.setText(this.getHelpMessage());
        });

        this.input.keyboard.on('keydown-FOUR', (event) =>
        {
            this.selectedShape = 'triangle';
            this.helpText.setText(this.getHelpMessage());
        });

        this.input.keyboard.on('keydown-C', (event) =>
        {
            this.onlyColliding = !this.onlyColliding;
            this.helpText.setText(this.getHelpMessage());
        });

        this.cameras.main.setBounds(0, 0, this.map.widthInPixels, this.map.heightInPixels);

        const cursors = this.input.keyboard.createCursorKeys();
        const controlConfig = {
            camera: this.cameras.main,
            left: cursors.left,
            right: cursors.right,
            up: cursors.up,
            down: cursors.down,
            speed: 0.5
        };
        this.controls = new Phaser.Cameras.Controls.FixedKeyControl(controlConfig);

        this.helpText = this.add.text(16, 16, this.getHelpMessage(), {
            fontSize: '18px',
            padding: { x: 10, y: 5 },
            fill: '#ffffff',
            backgroundColor: '#000000'
        });
        this.helpText.setScrollFactor(0);

        this.input.on('pointerdown', () =>
        {

            // Update p1 & p2 based on where user clicks
            const worldPoint = this.input.activePointer.positionToCamera(this.cameras.main);
            if (!this.p1)
            {
                this.p1 = worldPoint.clone();
            }
            else if (!this.p2)
            {
                this.p2 = worldPoint.clone();
            }
            else
            {
                this.p1 = worldPoint.clone();
                this.p2 = null;
            }

        });
    }

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

        // Show user where they clicked
        if (this.p1) { this.graphics.fillCircle(this.p1.x, this.p1.y, 3); }
        if (this.p2) { this.graphics.fillCircle(this.p2.x, this.p2.y, 3); }

        // If we have both points, draw a shape and manipulate the tiles in that shape
        if (this.p1 && this.p2)
        {
            this.map.forEachTile((tile) => { tile.alpha = 1; });

            let overlappingTiles = [];

            switch (this.selectedShape)
            {
                case 'rectangle': {
                    const xStart = Math.min(this.p1.x, this.p2.x);
                    const yStart = Math.min(this.p1.y, this.p2.y);
                    const xEnd = Math.max(this.p1.x, this.p2.x);
                    const yEnd = Math.max(this.p1.y, this.p2.y);
                    const rect = new Phaser.Geom.Rectangle(xStart, yStart, xEnd - xStart, yEnd - yStart);
                    overlappingTiles = this.map.getTilesWithinShape(rect, { isColliding: this.onlyColliding });
                    this.graphics.strokeRectShape(rect);
                    break; }
                case 'line': {
                    const line = new Phaser.Geom.Line(this.p1.x, this.p1.y, this.p2.x, this.p2.y);
                    overlappingTiles = this.map.getTilesWithinShape(line, { isColliding: this.onlyColliding });
                    this.graphics.strokeLineShape(line);
                    break; }
                case 'circle': {
                    const radius = Math.sqrt(Math.pow(this.p2.x - this.p1.x, 2) + Math.pow(this.p2.y - this.p1.y, 2)) / 2;
                    const cx = (this.p1.x + this.p2.x) / 2;
                    const cy = (this.p1.y + this.p2.y) / 2;
                    const circle = new Phaser.Geom.Circle(cx, cy, radius);
                    overlappingTiles = this.map.getTilesWithinShape(circle, { isColliding: this.onlyColliding });
                    this.graphics.strokeCircleShape(circle);
                    break; }
                case 'triangle': {
                    const tri = new Phaser.Geom.Triangle(this.p1.x, this.p1.y, this.p1.x, this.p2.y, this.p2.x, this.p2.y);
                    overlappingTiles = this.map.getTilesWithinShape(tri, { isColliding: this.onlyColliding });
                    this.graphics.strokeTriangleShape(tri);
                    break; }
                default:
                    break;
            }

            overlappingTiles.forEach((tile) => { tile.alpha = 0.25; });
        }

    }

    getHelpMessage ()
    {
        return `Click to draw. Press 1/2/3/4 to change shapes.\nSelected shape: ${this.selectedShape}\nPress C to only select colliding tiles: ${this.onlyColliding ? 'on' : 'off'}\nArrows to scroll.`;
    }
}

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

const game = new Phaser.Game(config);


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

Вся логика примера реализована в классе сцены. В методе preload загружаются данные тайлмапы в формате JSON и tileset-изображение.

preload ()
{
    this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
    this.load.tilemapTiledJSON('map', 'assets/tilemaps/maps/cybernoid.json');
    this.load.image('cybernoid', 'assets/tilemaps/tiles/cybernoid.png');
}

В create мы создаем тайлмапу, слой и настраиваем коллизии. Ключевой момент — вызов this.map.setCollisionByExclusion(7), который помечает все тайлы, кроме тайла с индексом 7, как имеющие коллизию. Это понадобится позже для фильтрации.

create ()
{
    this.map = this.add.tilemap('map');
    const tiles = this.map.addTilesetImage('cybernoid');
    const layer = this.map.createLayer(0, tiles);
    layer.setScale(1.25, 1.25);
    this.map.setCollisionByExclusion(7);
}

Также здесь создается графика (this.graphics) для отрисовки фигур и настраивается управление камерой с помощью Phaser.Cameras.Controls.FixedKeyControl.

Обработка ввода: выбор фигуры и точек

Пользователь управляет примером с помощью клавиш и мыши. Нажатия на клавиши 1-4 переключают тип выбираемой фигуры, а клавиша C — режим фильтрации (все тайлы или только те, у которых включена коллизия).

this.input.keyboard.on('keydown-ONE', (event) =>
{
    this.selectedShape = 'rectangle';
    this.helpText.setText(this.getHelpMessage());
});
// ... аналогичные обработчики для TWO, THREE, FOUR
this.input.keyboard.on('keydown-C', (event) =>
{
    this.onlyColliding = !this.onlyColliding;
    this.helpText.setText(this.getHelpMessage());
});

Логика выделения области строится на двух точках (p1 и p2). При первом клике мыши запоминается p1, при втором — p2. Третий клик сбрасывает p2 и начинает новое выделение. Координаты клика переводятся в мировую систему камеры с помощью positionToCamera.

this.input.on('pointerdown', () =>
{
    const worldPoint = this.input.activePointer.positionToCamera(this.cameras.main);
    if (!this.p1)
    {
        this.p1 = worldPoint.clone();
    }
    else if (!this.p2)
    {
        this.p2 = worldPoint.clone();
    }
    else
    {
        this.p1 = worldPoint.clone();
        this.p2 = null;
    }
});

Ядро логики: метод getTilesWithinShape

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

Метод `this.map.getTilesWithinShape` — главный инструмент. Он принимает два аргумента:
1.  Объект фигуры из `Phaser.Geom` (Rectangle, Line, Circle, Triangle).
2.  Опциональный объект конфигурации. В нашем примере используется опция `{ isColliding: this.onlyColliding }`. Если `onlyColliding` равно `true`, метод вернет только те тайлы, у которых включено свойство коллизии.

Рассмотрим логику для каждой фигуры:

**Прямоугольник:** Фигура строится по двум противоположным углам. Координаты упорядочиваются по минимальным и максимальным значениям.

case 'rectangle': {
    const xStart = Math.min(this.p1.x, this.p2.x);
    const yStart = Math.min(this.p1.y, this.p2.y);
    const xEnd = Math.max(this.p1.x, this.p2.x);
    const yEnd = Math.max(this.p1.y, this.p2.y);
    const rect = new Phaser.Geom.Rectangle(xStart, yStart, xEnd - xStart, yEnd - yStart);
    overlappingTiles = this.map.getTilesWithinShape(rect, { isColliding: this.onlyColliding });
    this.graphics.strokeRectShape(rect);
    break; }

**Линия:** Фигура — это отрезок между точками p1 и p2.

case 'line': {
    const line = new Phaser.Geom.Line(this.p1.x, this.p1.y, this.p2.x, this.p2.y);
    overlappingTiles = this.map.getTilesWithinShape(line, { isColliding: this.onlyColliding });
    this.graphics.strokeLineShape(line);
    break; }

**Круг:** Центр круга — середина отрезка между p1 и p2, а радиус равен половине длины этого отрезка.

case 'circle': {
    const radius = Math.sqrt(Math.pow(this.p2.x - this.p1.x, 2) + Math.pow(this.p2.y - this.p1.y, 2)) / 2;
    const cx = (this.p1.x + this.p2.x) / 2;
    const cy = (this.p1.y + this.p2.y) / 2;
    const circle = new Phaser.Geom.Circle(cx, cy, radius);
    overlappingTiles = this.map.getTilesWithinShape(circle, { isColliding: this.onlyColliding });
    this.graphics.strokeCircleShape(circle);
    break; }

**Треугольник:** Строится по трем точкам, две из которых зависят от p1 и p2.

case 'triangle': {
    const tri = new Phaser.Geom.Triangle(this.p1.x, this.p1.y, this.p1.x, this.p2.y, this.p2.x, this.p2.y);
    overlappingTiles = this.map.getTilesWithinShape(tri, { isColliding: this.onlyColliding });
    this.graphics.strokeTriangleShape(tri);
    break; }

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

overlappingTiles.forEach((tile) => { tile.alpha = 0.25; });

Практическое применение и расширение

Метод getTilesWithinShape открывает множество возможностей:

* **Инструменты для редактора уровней:** Выделение и массовое редактирование тайлов (изменение типа, удаление). * **Геймплейные механики:** Определение зоны поражения от взрыва (круг) или площади эффекта заклинания (прямоугольник). * **Логические проверки:** Можно проверить, свободен ли путь (линия) для выстрела или находится ли игрок в безопасной зоне (полигон).

Вы можете расширить пример, добавив возможность не просто подсвечивать тайлы, а изменять их свойства. Например, сделать тайлы разрушаемыми или наносить урон юнитам, находящимся в выделенной области.

// Пример: изменить индекс тайла на 0 (пустой) для всех найденных тайлов
overlappingTiles.forEach((tile) => {
    tile.index = 0;
    tile.alpha = 1;
});

Также можно экспериментировать с другими опциями конфигурации метода, например, { isEmpty: true } для выборки только пустых тайлов.

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

Метод getTilesWithinShape — это мощный и гибкий инструмент для работы с тайлмапами в Phaser. Он позволяет перейти от работы с отдельными тайлами к работе с областями сложной формы, что критически важно для создания продвинутых игровых механик и инструментов разработки. Для экспериментов попробуйте: 1. Добавить новые типы фигур, например, эллипс или произвольный полигон (через Phaser.Geom.Polygon). 2. Реализовать "кисть" для рисования тайлами: при движении мыши с зажатой кнопкой постоянно получать тайлы в форме небольшого круга под курсором и заменять их. 3. Создать систему выделения и копирования/вставки фрагментов карты, используя прямоугольное выделение и методы работы с данными тайлов.