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

При создании игр с физикой часто требуется реалистичное взаимодействие объектов нестандартной формы. Использовать простые прямоугольники или круги для сложных спрайтов — не вариант. В этой статье разберем, как создавать составные (compound) физические тела в Phaser 3, используя движок Matter.js и данные из редактора Physics Editor. Этот подход позволяет точно соответствовать визуальной форме объекта и экономит время на ручном подборе коллайдеров.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('ball', 'assets/sprites/green_ball.png');

        this.load.image('image', 'assets/physics/compound.png');

        this.load.json('shapes', 'assets/physics/compound.json');
    }

    create ()
    {
        this.matter.world.setBounds(0, 0, 800, 600, 32, true, true, false, true);

        const Body = Phaser.Physics.Matter.Matter.Body;
        const Bodies = Phaser.Physics.Matter.Matter.Bodies;
        const Composite = Phaser.Physics.Matter.Matter.Composite;
        const Parser = Phaser.Physics.Matter.PhysicsEditorParser;

        const shapes = this.cache.json.get('shapes');

        const composite = Composite.create();

        const parts = [ 'bird', 'p', 'h', 'a', 's', 'e', 'r' ];

        for (let i = 0; i < parts.length; i++)
        {
            const body = Body.create({ isStatic: true });

            Body.setParts(body, Parser.parseVertices(shapes[parts[i]].fixtures[0].vertices));

            Composite.addBody(composite, body);
        }

        /*
        var fixtures = shapes.logo.fixtures;

        for (var i = 0; i < fixtures.length; i++)
        {
            var body = Body.create({ isStatic: true });

            Body.setParts(body, parseVertices(fixtures[i].vertices));

            Composite.addBody(composite, body);
        }
        */

        Composite.translate(composite, { x: 0, y: 150 });

        this.matter.world.add(composite);

        this.add.image(400, 300, 'image');

        for (let i = 0; i < 64; i++)
        {
            const ball = this.matter.add.image(Phaser.Math.Between(100, 700), Phaser.Math.Between(-600, 0), 'ball');
            ball.setCircle();
            ball.setFriction(0.005);
            ball.setBounce(1);
        }
    }

    parseVertices (vertexSets, options)
    {
        const Matter = Phaser.Physics.Matter.Matter;

        let i, j, k, v, z;
        const parts = [];

        options = options || {};

        for (v = 0; v < vertexSets.length; v += 1)
        {
            parts.push(Matter.Body.create(Matter.Common.extend({
                position: Matter.Vertices.centre(vertexSets[v]),
                vertices: vertexSets[v]
            }, options)));
        }

        // flag coincident part edges
        const coincidentMaxDist = 5;

        for (i = 0; i < parts.length; i++)
        {
            const partA = parts[i];

            for (j = i + 1; j < parts.length; j++)
            {
                const partB = parts[j];

                if (Matter.Bounds.overlaps(partA.bounds, partB.bounds))
                {
                    const pav = partA.vertices,
                        pbv = partB.vertices;

                    // iterate vertices of both parts
                    for (k = 0; k < partA.vertices.length; k++)
                    {
                        for (z = 0; z < partB.vertices.length; z++)
                        {
                            // find distances between the vertices
                            const da = Matter.Vector.magnitudeSquared(Matter.Vector.sub(pav[(k + 1) % pav.length], pbv[z])),
                                db = Matter.Vector.magnitudeSquared(Matter.Vector.sub(pav[k], pbv[(z + 1) % pbv.length]));

                            // if both vertices are very close, consider the edge concident (internal)
                            if (da < coincidentMaxDist && db < coincidentMaxDist)
                            {
                                pav[k].isInternal = true;
                                pbv[z].isInternal = true;
                            }
                        }
                    }

                }
            }
        }

        return parts;
    }
}

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

const game = new Phaser.Game(config);

Загрузка данных: изображение и JSON-фигуры

Основа работы — это разделение визуального представления объекта и его физической модели. Визуал загружается как обычная текстура, а данные о сложной форме — как JSON-файл, экспортированный из редактора Physics Editor.

this.load.image('image', 'assets/physics/compound.png');
this.load.json('shapes', 'assets/physics/compound.json');

Загруженный JSON содержит массив фигур (fixtures). Каждая фигура описывается массивом вершин (vertices) в формате, понятном для Matter.js. Это позволяет описывать одну сложную форму (например, логотип) как набор простых полигонов.

Создание составного тела (Composite Body)

В Matter.js составное тело создается через Composite. Это контейнер, который может объединять несколько простых тел (Body) в единую физическую сущность.

const composite = Composite.create();

В примере мы видим два подхода. Первый — перебор частей по их именам из JSON ('bird', 'p', 'h'...). Для каждой части создается статическое тело, а затем его форма задается через Body.setParts() и парсер PhysicsEditorParser.

const body = Body.create({ isStatic: true });
Body.setParts(body, Parser.parseVertices(shapes[parts[i]].fixtures[0].vertices));
Composite.addBody(composite, body);

Парсер Phaser.Physics.Matter.PhysicsEditorParser (Parser в коде) преобразует массив вершин из JSON в формат, пригодный для задания частей тела Matter.js. Это ключевой мост между редактором и игровым движком.

Альтернативный подход: ручной парсинг вершин

В закомментированном блоке кода показан альтернативный, более низкоуровневый подход. Здесь используется кастомная функция parseVertices, написанная непосредственно в классе сцены.

parseVertices (vertexSets, options) {
    // ... логика создания тел из массивов вершин
    // ... и обработка совпадающих границ
}

Эта функция не только создает тела из переданных наборов вершин, но и проводит дополнительную оптимизацию. Она находит границы (edges) соседних частей, которые находятся очень близко (в пределах coincidentMaxDist), и помечает их как внутренние (isInternal = true). Это помогает движку Matter.js корректно обрабатывать стыки внутри составного тела, избегая лишних вычислений столкновений внутри самого объекта.

Интеграция в мир и добавление динамических объектов

После сборки составного тела его нужно правильно разместить в мире и добавить в физическую симуляцию.

Composite.translate(composite, { x: 0, y: 150 });
this.matter.world.add(composite);
this.add.image(400, 300, 'image');

Сначала составное тело сдвигается на нужную позицию с помощью Composite.translate. Затем оно добавляется в физический мир через this.matter.world.add. Важно: визуальное изображение (image) добавляется отдельно, как обычный спрайт, и его позиция должна совпадать с позицией физического тела.

Для демонстрации взаимодействия в сцене создаются динамические шары. Обратите внимание на настройки физики:

ball.setCircle();
ball.setFriction(0.005);
ball.setBounce(1);

setCircle() задает шарообразную форму коллайдера. Ультранизкое трение (friction: 0.005) и упругость (bounce: 1) позволяют шарам долго и энергично отскакивать от статического составного тела, наглядно демонстрируя его сложную форму.

Конфигурация Physics Debug

Для отладки сложных физических форм крайне полезен режим отладки. Он включается в конфигурации игры.

physics: {
    default: 'matter',
    matter: {
        debug: true
    }
}

Когда debug: true, поверх спрайтов отрисовываются контуры всех физических тел. Вы увидите не просто прямоугольник вокруг всего логотипа, а множество полигонов, точно соответствующих каждой букве и фигуре. Это незаменимый инструмент для проверки корректности экспорта данных из Physics Editor и их загрузки в игру.

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

Использование составных тел через Physics Editor кардинально упрощает создание реалистичной физики для объектов сложной формы. Основной вывод: разделяйте визуал и данные физической модели. Экспериментируйте: попробуйте сделать составное тело динамическим (уберите isStatic: true) и посмотрите на его поведение. Или загрузите свою текстуру, создайте для нее коллайдер в Physics Editor и импортируйте в свой проект, чтобы разрушать ее шарами.