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

При разработке платформеров или игр с детальной физикой на Phaser вы могли столкнуться с необъяснимыми рывками объектов при движении по соседним тайлам. Это явление называется «призрачными коллизиями» (ghost collisions). Оно возникает из-за особенностей разрешения столкновений в физических движках, когда объект застревает на стыке двух статических тел. В этой статье мы разберем практический пример из официальной документации Phaser, который демонстрирует две стратегии решения этой проблемы: использование отдельных тел для тайлов и создание единого выпуклого тела для сложных платформ. Вы научитесь оптимизировать физику вашего уровня, делая движение объектов плавным и предсказуемым.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    map;
    cam;
    text;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.tilemapTiledJSON('map', 'assets/tilemaps/maps/matter-ghost-vertices.json');
        this.load.image('kenney_redux_64x64', 'assets/tilemaps/tiles/kenney_redux_64x64.png');
        this.load.image('ball', 'assets/sprites/mushroom-32x32.png');
    }

    create ()
    {
        this.map = this.make.tilemap({ key: 'map' });
        const tileset = this.map.addTilesetImage('kenney_redux_64x64');
        const layer = this.map.createLayer(0, tileset, 0, 0);

        // "Ghost collisions" can happen in physics engines when two colliding bodies are next to one
        // another, e.g. a player trying to walk over two neighboring ground tiles. The order in which
        // the collisions are resolved by the engine can cause "unrealistic" effects, e.g. the player
        // being stopping dead in their tracks on flat ground). See
        // http://www.iforce2d.net/b2dtut/ghost-vertices for more info. When working with tilemaps and
        // Matter, there are a couple ways to mitigate this issue:
        //  - Add chamfer to bodies, i.e. round the edges, or use circular bodies to reduce the impact
        //    of the ghost collisions.
        //  - Map out your level's hitboxes as as a few convex hulls instead of giving each tile a
        //    separate body. You can still use Tiled for this. Create an object layer, and fill it
        //    with shapes, convert those shapes to Matter bodies in Phaser (see below).
        //  - Use a library like hull.js (https://github.com/AndriiHeonia/hull) to automatically figure
        //    out convex hulls from your tiles.

        // Set up the grass tiles to have individual matter bodies.
        layer.setCollisionByProperty({ type: 'grass' });
        this.matter.world.convertTilemapLayer(layer);

        // The stone platform has been mapped as a single, long rectangle in Tiled. See
        // Phaser.Physics.Matter.TileBody#setFromTileCollision for how to parse other Tiled shapes.
        const rect = this.map.findObject('Collision', obj => obj.name === 'Stone Platform');
        this.matter.add.rectangle(
            rect.x + (rect.width / 2), rect.y + (rect.height / 2),
            rect.width, rect.height,
            { isStatic: true }
        );

        this.cam = this.cameras.main;
        this.cam.setBounds(0, 0, this.map.widthInPixels, this.map.heightInPixels);
        this.cam.setScroll(0, 700);

        this.time.addEvent({
            delay: 500,
            callback: function ()
            {
                const shroom1 = this.matter.add.image(10, 1200, 'ball');
                shroom1.setRectangle();
                shroom1.setFriction(0);
                shroom1.body.force.x = 0.05;
                this.time.addEvent({ delay: 2000, callback: this.destroyShroom.bind(this, shroom1) });

                const shroom2 = this.matter.add.image(10, 880, 'ball');
                shroom2.setRectangle();
                shroom2.setFriction(0);
                shroom2.body.force.x = 0.05;
                this.time.addEvent({ delay: 2000, callback: this.destroyShroom.bind(this, shroom2) });
            },
            callbackScope: this,
            loop: true
        });

        this.matter.world.setBounds(this.map.widthInPixels, this.map.heightInPixels);

        this.text = this.add.text(16, 16, 'Ghost Collisions Demo\nGrass: Individual Tile Bodies\nStone: A Single Convex Body', {
            fontSize: '20px',
            padding: { x: 20, y: 10 },
            backgroundColor: '#ffffff',
            fill: '#000000'
        });
        this.text.setScrollFactor(0);
    }

    destroyShroom (shroom)
    {
        this.matter.world.remove(shroom);
        shroom.destroy();
    }
}

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

const game = new Phaser.Game(config);

Что такое призрачные коллизии и почему они возникают?

Призрачные коллизии — это артефакт физического движка, который проявляется, когда динамическое тело (например, игрок) пытается переместиться по нескольким соседним статическим телам (например, плиткам пола). Движок Matter.js, как и другие, обрабатывает коллизии в определенном порядке. Если два статических тела расположены вплотную, движок может «запутаться» и решить, что динамический объект столкнулся с воображаемым («призрачным») препятствием на их стыке, что приводит к резкой остановке или подергиванию.

В примере эта проблема наглядно показана: гриб (shroom1), движущийся по траве, состоящей из множества отдельных тайлов, может испытывать небольшие рывки. Для травы используется первый подход — каждому тайлу присваивается собственное физическое тело.

layer.setCollisionByProperty({ type: 'grass' });
this.matter.world.convertTilemapLayer(layer);

Здесь setCollisionByProperty находит все тайлы слоя, у которых в свойствах Tiled задано type: 'grass'. Метод convertTilemapLayer автоматически создает для каждого такого тайла статическое тело Matter.js. Это простой, но подверженный призрачным коллизиям метод.

Стратегия 1: Округление краев и уменьшение трения

Первый способ смягчить проблему — изменить форму или свойства динамических тел, чтобы минимизировать «зацепления». В примере для движущихся грибов это сделано двумя способами.

Во-первых, используется setRectangle(), который создает прямоугольный коллайдер, более оптимизированный, чем коллайдер по умолчанию от изображения. Во-вторых, трение устанавливается в ноль. Это не решает проблему на уровне статической геометрии, но делает движение динамических объектов более гладким, уменьшая силу, которая может усугубить эффект призрачной коллизии.

const shroom1 = this.matter.add.image(10, 1200, 'ball');
shroom1.setRectangle(); // Создает прямоугольный коллайдер
shroom1.setFriction(0); // Убирает трение
shroom1.body.force.x = 0.05; // Применяет постоянную силу для движения

Это временное или частичное решение, которое хорошо работает для простых случаев или прототипов.

Стратегия 2: Объединение геометрии в выпуклые тела

Более радикальный и эффективный метод — перепроектировать статическую геометрию уровня. Вместо сотен тел для каждого тайла мы создаем несколько крупных выпуклых оболочек (convex hull). В примере каменная платформа реализована именно так.

В редакторе Tiled был создан отдельный слой объектов (Object Layer) с именем 'Collision'. В нем нарисован один прямоугольный объект с именем 'Stone Platform'. В коде Phaser мы находим этот объект и создаем на его основе одно статическое тело Matter.js.

const rect = this.map.findObject('Collision', obj => obj.name === 'Stone Platform');
this.matter.add.rectangle(
    rect.x + (rect.width / 2), rect.y + (rect.height / 2),
    rect.width, rect.height,
    { isStatic: true }
);

Функция findObject ищет объект по имени в слое 'Collision'. Поскольку координаты объекта в Tiled указывают на его верхний левый угол, мы корректируем их, прибавляя половину ширины и высоты, чтобы правильно разместить центр тела Matter. Параметр isStatic: true делает платформу неподвижной. Гриб (shroom2), движущийся по такой платформе, не испытывает призрачных коллизий, так как для него вся платформа — это одно непрерывное тело.

Как автоматизировать создание выпуклых оболочек

Вручную рисовать объекты для сложного уровня утомительно. Процесс можно автоматизировать, используя алгоритмы для построения выпуклых оболочек. В комментариях к исходному коду упоминается библиотека hull.js.

Общая идея такова: 1. Экспортируйте данные о положении всех коллизионных тайлов вашего уровня. 2. Передайте массив их центральных точек в алгоритм построения выпуклой оболочки. 3. Получите набор вершин, описывающих один или несколько крупных полигонов, которые охватывают ваши тайлы. 4. Импортируйте эти полигоны в Tiled как объекты или создайте тела Matter.js напрямую из полученных данных в Phaser, используя this.matter.add.fromVertices.

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

Настройка мира и камеры для демонстрации

Код примера также содержит важные настройки сцены, которые обеспечивают корректное отображение и работу физики.

// Устанавливаем границы камеры по размеру карты и задаем начальную позицию
this.cam.setBounds(0, 0, this.map.widthInPixels, this.map.heightInPixels);
this.cam.setScroll(0, 700);

// Устанавливаем границы физического мира
this.matter.world.setBounds(this.map.widthInPixels, this.map.heightInPixels);

Установка границ для камеры (setBounds) позволяет ей следить за объектами в пределах всей карты. setScroll(0, 700) сразу позиционирует камеру в нижней части уровня, где происходит основное действие. Границы физического мира (setBounds) критически важны: они создают статические стены по краям сцены, которые не дадут динамическим телам (грибам) бесконечно улетать за ее пределы. Без этого тела, на которые действует сила, просто исчезли бы из вида.

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

Призрачные коллизии — это решаемая проблема. Выбор стратегии зависит от сложности вашего проекта. Для прототипов или декоративных элементов подойдет подход с отдельными тайлами и настройкой динамических тел (setFriction(0)). Для продакшена, особенно в платформерах, обязательно объединяйте геометрию в выпуклые тела, рисуя их в Tiled или генерируя автоматически. Для экспериментов попробуйте: изменить силу body.force.x у грибов, добавить больше платформ-объектов разной формы в Tiled или реализовать простой алгоритм объединения соседних тайлов травы в более крупные прямоугольники прямо в коде create.