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

Создание коллизий для платформера или топ-даун игры часто сводится к простым прямоугольникам, но что делать, если форма объекта сложная? Ручная расстановка множества мелких хитбоксов утомительна и неэффективна. Пример показывает, как загрузить тайлмап из редактора Tiled, где для тайлов уже заданы полигональные, эллиптические и составные формы столкновений, и программно отрисовать их в Phaser. Этот подход позволяет дизайнеру уровней один раз точно настроить физику в удобном редакторе, а разработчику — легко импортировать и визуализировать результат, что значительно ускоряет итерации.

Версия 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.tilemapTiledJSON('map', 'assets/tilemaps/maps/tileset-collision-shapes.json');
        this.load.image('kenny_platformer_64x64', 'assets/tilemaps/tiles/kenny_platformer_64x64.png');
    }

    create ()
    {
        const map = this.add.tilemap('map');
        const tileset = map.addTilesetImage('kenny_platformer_64x64');
        const layer = map.createLayer('Tile Layer 1', tileset);

        const graphics = this.add.graphics();

        // Loop over each tile and visualize its collision shape (if it has one)
        layer.forEachTile(tile =>
        {
            const tileWorldPos = layer.tileToWorldXY(tile.x, tile.y);
            const collisionGroup = tileset.getTileCollisionGroup(tile.index);

            if (!collisionGroup || collisionGroup.objects.length === 0) { return; }

            // You can assign custom properties to the whole collision object layer (or even to
            // individual objects within the layer). Here, use a custom property to change the color of
            // the stroke.
            if (collisionGroup.properties && collisionGroup.properties.isInteractive)
            {
                graphics.lineStyle(5, 0x00ff00, 1);
            }
            else
            {
                graphics.lineStyle(5, 0x00ffff, 1);
            }

            // The group will have an array of objects - these are the individual collision shapes
            const objects = collisionGroup.objects;

            for (let i = 0; i < objects.length; i++)
            {
                const object = objects[i];
                const objectX = tileWorldPos.x + object.x;
                const objectY = tileWorldPos.y + object.y;

                // When objects are parsed by Phaser, they will be guaranteed to have one of the
                // following properties if they are a rectangle/ellipse/polygon/polyline.
                if (object.rectangle)
                {
                    graphics.strokeRect(objectX, objectY, object.width, object.height);
                }
                else if (object.ellipse)
                {
                    // Ellipses in Tiled have a top-left origin, while ellipses in Phaser have a center
                    // origin
                    graphics.strokeEllipse(
                        objectX + object.width / 2, objectY + object.height / 2,
                        object.width, object.height
                    );
                }
                else if (object.polygon || object.polyline)
                {
                    const originalPoints = object.polygon ? object.polygon : object.polyline;
                    const points = [];
                    for (let j = 0; j < originalPoints.length; j++)
                    {
                        const point = originalPoints[j];
                        points.push({
                            x: objectX + point.x,
                            y: objectY + point.y
                        });
                    }
                    graphics.strokePoints(points);
                }
            }
        });

        const help = this.add.text(16, 16, 'Collision shapes, parsed from Tiled', {
            fontSize: '20px',
            padding: { x: 20, y: 10 },
            backgroundColor: '#ffffff',
            fill: '#000000'
        });
        help.setScrollFactor(0);

        this.cameras.main.setScroll(80, 110);

        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);
    }

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

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

const game = new Phaser.Game(config);

Загрузка карты и подготовка слоя

В методе preload загружается JSON-файл карты, созданной в Tiled, и изображение тайлсета. Ключевой момент — в Tiled для тайлов можно задавать не просто прямоугольные коллизии, а произвольные формы (полигоны, полилинии, эллипсы), которые Phaser сможет распарсить.

В методе create создается объект тайлмапа, к нему привязывается изображение тайлсета, и на основе этого создается слой для отрисовки. Именно по этому слою мы будем итерироваться.

const map = this.add.tilemap('map');
const tileset = map.addTilesetImage('kenny_platformer_64x64');
const layer = map.createLayer('Tile Layer 1', tileset);

Итерация по тайлам и получение данных о коллизиях

Для визуализации нам потребуется объект Graphics. Мы перебираем каждый тайл на слое с помощью метода layer.forEachTile(). Для каждого тайла получаем его позицию в мировых координатах и, что самое важное, данные о группе коллизий через tileset.getTileCollisionGroup(tile.index).

const graphics = this.add.graphics();
layer.forEachTile(tile => {
    const tileWorldPos = layer.tileToWorldXY(tile.x, tile.y);
    const collisionGroup = tileset.getTileCollisionGroup(tile.index);
    if (!collisionGroup || collisionGroup.objects.length === 0) { return; }
    // ... Дальнейшая обработка collisionGroup
});

Если группа коллизий существует и содержит объекты, мы можем их обрабатывать. В примере также показано, как читать пользовательские свойства (custom properties) из Tiled, например, для изменения цвета отрисовки.

Отрисовка различных типов форм

Группа коллизий (collisionGroup) содержит массив объектов (objects). Каждый объект гарантированно будет иметь одно из свойств: rectangle, ellipse, polygon или polyline, в зависимости от своей формы в Tiled. Наша задача — корректно преобразовать локальные координаты формы (относительно тайла) в мировые и отрисовать её.

**Прямоугольник (rectangle)** — самый простой случай. Отрисовываем с учётом смещения.

if (object.rectangle) {
    graphics.strokeRect(objectX, objectY, object.width, object.height);
}

**Эллипс (ellipse)** — требует коррекции точки начала координат. В Tiled эллипс задаётся от левого верхнего угла обрамляющего прямоугольника, а в Phaser strokeEllipse принимает координаты центра. Поэтому к позиции добавляется половина ширины и высоты.

else if (object.ellipse) {
    graphics.strokeEllipse(
        objectX + object.width / 2, objectY + object.height / 2,
        object.width, object.height
    );
}

**Полигон и полилиния (polygon/polyline)** — обрабатываются одинаково. Массив точек, каждая из которых задана относительно тайла, преобразуется в массив точек в мировых координатах, который затем передаётся в graphics.strokePoints().

else if (object.polygon || object.polyline) {
    const originalPoints = object.polygon ? object.polygon : object.polyline;
    const points = [];
    for (let j = 0; j < originalPoints.length; j++) {
        const point = originalPoints[j];
        points.push({ x: objectX + point.x, y: objectY + point.y });
    }
    graphics.strokePoints(points);
}

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

Поскольку карта может быть большой, в примере реализована камера с плавным управлением от клавиш-стрелок. Это позволяет свободно перемещаться по уровню и рассматривать все отрисованные формы.

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

this.cameras.main.setScroll(80, 110);
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);

В методе update происходит обновление состояния этих контролов.

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

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

Пример наглядно демонстрирует мощную связку Tiled и Phaser для работы со сложной геометрией коллизий. Вместо того чтобы аппроксимировать объекты набором примитивов в коде, вы можете один раз и точно описать их в редакторе уровней. Полученные данные легко визуализировать для отладки, а в дальнейшем — использовать для инициализации физических тел (например, в Arcade или Matter Physics). Для экспериментов попробуйте

  1. вместо отрисовки создавать на основе этих форм статические физические тела
  2. использовать пользовательские свойства из Tiled для задания типа материала (лёд, грязь) и применять их к физическому взаимодействию
  3. реализовать загрузку только «интерактивных» коллизий, отфильтровав их по своему свойству