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

При создании сложных игровых объектов, состоящих из нескольких спрайтов, возникает вопрос: как заставить всю эту конструкцию корректно взаимодействовать с физическим миром? Обычные группы (`Phaser.GameObjects.Group`) не имеют физического тела. В этой статье мы разберем, как с помощью `Phaser.GameObjects.Container` и метода `this.matter.add.gameObject()` создать составной объект с единой физикой, который можно толкать, бросать и заставлять сталкиваться с другими телами. Этот подход полезен для создания разрушаемых объектов, сложных персонажей или любых интерактивных композиций из нескольких изображений.

Версия 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('mushroom', 'assets/sprites/mushroom2.png');
        this.load.image('block', 'assets/sprites/block.png');
    }

    create ()
    {
        const image1 = this.add.image(0, -30, 'mushroom').setName('mushroom1');
        const image2 = this.add.image(-40, 30, 'mushroom').setName('mushroom2');
        const image3 = this.add.image(40, 30, 'mushroom').setName('mushroom3');

        const container = this.add.container(400, 300, [ image1, image2, image3 ]);

        //  A Container has a default size of 0x0
        //  so we need to give it a size before enabling a physics body
        container.setSize(128, 64);

        const physicsContainer = this.matter.add.gameObject(container);

        physicsContainer.setFrictionAir(0.001);
        physicsContainer.setBounce(1);

        const blockA = this.matter.add.image(100, 300, 'block').setBounce(1).setFriction(0);
        const blockB = this.matter.add.image(700, 300, 'block').setStatic(true);

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

            physicsContainer.setVelocityX(6);

        });

        const tintOnCollision = (bodyA, bodyB) =>
        {

            if (bodyA.gameObject.list)
            {
                bodyA.gameObject.next?.setTint(0xff0000);
            }
            else if (bodyA.gameObject)
            {
                bodyA.gameObject.setTint(0xff0000);
            }

            if (bodyB.gameObject.list)
            {
                bodyB.gameObject.next?.setTint(0xff0000);
            }
            else if (bodyB.gameObject)
            {
                bodyB.gameObject.setTint(0xff0000);
            }

        };

        physicsContainer.setOnCollide(pair =>
        {

            const bodyA = pair.bodyA;
            const bodyB = pair.bodyB;

            tintOnCollision(bodyA, bodyB);

        });

        //  Or you can do it like this:

        /*
        this.matter.world.on('collisionstart', (event, bodyA, bodyB) => {

            tintOnCollision(bodyA, bodyB);

        });
        */
    }
}

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

const game = new Phaser.Game(config);

Создание контейнера и его физического представления

Контейнер (Container) — это игровой объект, который может содержать в себе другие игровые объекты, образуя единую иерархию. Однако по умолчанию он не имеет физического тела и не может участвовать в коллизиях.

Ключевой шаг — превратить контейнер в физический объект.

const container = this.add.container(400, 300, [ image1, image2, image3 ]);
container.setSize(128, 64);
const physicsContainer = this.matter.add.gameObject(container);

Метод this.matter.add.gameObject() создает и привязывает к контейнеру тело Matter.js. Важно: перед этим необходимо задать контейнеру размер через setSize(), так как по умолчанию его размер равен 0x0, и физическое тело не сможет быть создано корректно. Теперь physicsContainer — это наш контейнер, но с добавленными свойствами и методами физического тела Matter.js, такими как setVelocityX() или setOnCollide().

Настройка физических свойств и управление

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

physicsContainer.setFrictionAir(0.001);
physicsContainer.setBounce(1);
this.input.once('pointerdown', () => {
    physicsContainer.setVelocityX(6);
});

Здесь мы задаем очень низкое сопротивление воздуха (setFrictionAir(0.001)), чтобы контейнер долго двигался по инерции, и упругость (setBounce(1)) для абсолютно упругих столкновений. По клику мыши (pointerdown) контейнеру придается горизонтальная скорость. Обратите внимание: методы физики вызываются у объекта physicsContainer, который был возвращен this.matter.add.gameObject().

Обработка столкновений и идентификация тел

Столкновения обрабатываются с помощью метода setOnCollide(), который срабатывает при каждом контакте тела контейнера с другим телом.

physicsContainer.setOnCollide(pair => {
    const bodyA = pair.bodyA;
    const bodyB = pair.bodyB;
    tintOnCollision(bodyA, bodyB);
});

Колбэк получает объект pair, содержащий ссылки на два столкнувшихся тела (bodyA и bodyB). Сложность в том, что bodyA может быть как телом нашего контейнера, так и телом другого объекта (например, блока). Вспомогательная функция tintOnCollision решает эту задачу.

const tintOnCollision = (bodyA, bodyB) => {
    if (bodyA.gameObject.list) {
        bodyA.gameObject.next?.setTint(0xff0000);
    } else if (bodyA.gameObject) {
        bodyA.gameObject.setTint(0xff0000);
    }
    // ... Аналогично для bodyB
};

Проверка bodyA.gameObject.list определяет, является ли игровой объект, привязанный к телу, контейнером (у контейнеров есть свойство list — массив дочерних объектов). Если это контейнер, мы красим его первого дочернего спрайта (bodyA.gameObject.next). Это специфичная для данного примера логика доступа к внутренним спрайтам контейнера. В противном случае (например, для обычного блока 'block'), мы просто красим сам игровой объект.

Альтернативный способ: глобальный слушатель столкновений

Помимо setOnCollide() для конкретного тела, Phaser Matter позволяет подписаться на глобальное событие столкновений всего мира. Этот способ закомментирован в исходном коде, но его полезно знать.

this.matter.world.on('collisionstart', (event, bodyA, bodyB) => {
    tintOnCollision(bodyA, bodyB);
});

Событие 'collisionstart' объекта this.matter.world срабатывает при начале любого столкновения в физическом мире. В колбэк передаются тела bodyA и bodyB. Использовать глобальный слушатель удобно, когда нужно централизованно обрабатывать столкновения множества разнородных объектов.

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

Комбинация Container и this.matter.add.gameObject() открывает мощный способ создавать сложные составные физические объекты. Вы можете экспериментировать: добавлять в контейнер не только спрайты, но и другие контейнеры, создавая иерархии; изменять форму физического тела контейнера с помощью setRectangle или полигонов; использовать контейнеры как части разрушаемой конструкции, удаляя дочерние спрайты при определенных столкновениях. Этот подход делает физику в вашей игре гибкой и выразительной.