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

При создании 2D-игр часто возникает задача детектировать столкновения не по всей площади спрайта, а только в определённых его частях. Например, нужно определить, какой именно стороной персонаж коснулся платформы. Phaser в связке с физическим движком Matter.js позволяет решать такие задачи элегантно с помощью составных тел (Compound Bodies) и сенсоров (Sensors). Эта статья покажет, как создать объект, состоящий из центрального физического тела и четырёх сенсоров по сторонам света. Каждый сенсор будет помечен и реагировать на столкновения независимо, окрашивая статичные блоки в свой цвет. Этот подход полезен для создания продвинутых механик взаимодействия, хитбоксов для ударов или зон взятия предметов.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    cursors;
    player;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('block', 'assets/sprites/block.png');
        this.load.image('platform', 'assets/sprites/platform.png');
    }

    create ()
    {
        const Bodies = Phaser.Physics.Matter.Matter.Bodies;

        const rect = Bodies.rectangle(0, 0, 98, 98);
        const circleA = Bodies.circle(-70, 0, 24, { isSensor: true, label: 'left' });
        const circleB = Bodies.circle(70, 0, 24, { isSensor: true, label: 'right' });
        const circleC = Bodies.circle(0, -70, 24, { isSensor: true, label: 'top' });
        const circleD = Bodies.circle(0, 70, 24, { isSensor: true, label: 'bottom' });

        const compoundBody = Phaser.Physics.Matter.Matter.Body.create({
            parts: [ rect, circleA, circleB, circleC, circleD ],
            inertia: Infinity
        });

        this.player = this.matter.add.image(0, 0, 'block');

        this.player.setExistingBody(compoundBody);
        this.player.setPosition(100, 300);

        const testA = this.matter.add.image(400, 150, 'block').setStatic(true);
        const testB = this.matter.add.image(600, 450, 'block').setStatic(true);
        const testC = this.matter.add.image(200, 550, 'block').setStatic(true);

        this.matter.world.on('collisionstart', event =>
        {

            //  Loop through all of the collision pairs
            const pairs = event.pairs;

            for (let i = 0; i < pairs.length; i++)
            {
                const bodyA = pairs[i].bodyA;
                const bodyB = pairs[i].bodyB;

                //  We only want sensor collisions
                if (pairs[i].isSensor)
                {
                    let blockBody;
                    let playerBody;

                    if (bodyA.isSensor)
                    {
                        blockBody = bodyB;
                        playerBody = bodyA;
                    }
                    else if (bodyB.isSensor)
                    {
                        blockBody = bodyA;
                        playerBody = bodyB;
                    }

                    //  You can get to the Sprite via `gameObject` property
                    const playerSprite = playerBody.gameObject;
                    const blockSprite = blockBody.gameObject;

                    let color;

                    if (playerBody.label === 'left')
                    {
                        color = 0xff0000;
                    }
                    else if (playerBody.label === 'right')
                    {
                        color = 0x00ff00;
                    }
                    else if (playerBody.label === 'top')
                    {
                        color = 0x0000ff;
                    }
                    else if (playerBody.label === 'bottom')
                    {
                        color = 0xffff00;
                    }

                    blockSprite.setTint(color).setTintMode(Phaser.TintModes.FILL);
                }
            }

        });

        this.cursors = this.input.keyboard.createCursorKeys();

        this.add.text(10, 10, 'Move with cursor keys. Hit blocks with sensors.', { font: '16px Courier', fill: '#ffffff' });
    }

    update ()
    {
        if (this.cursors.left.isDown)
        {
            this.player.setVelocityX(-10);
        }
        else if (this.cursors.right.isDown)
        {
            this.player.setVelocityX(10);
        }
        else
        {
            this.player.setVelocityX(0);
        }

        if (this.cursors.up.isDown)
        {
            this.player.setVelocityY(-10);
        }
        else if (this.cursors.down.isDown)
        {
            this.player.setVelocityY(10);
        }
        else
        {
            this.player.setVelocityY(0);
        }
    }
}

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

Подготовка и создание составного тела

В методе create() происходит основная настройка физики и создание игровых объектов. Ключевой момент — создание составного тела из нескольких частей с помощью Matter.js.

Сначала импортируется фабрика тел Bodies из Matter. Затем создаётся центральный прямоугольник и четыре круга-сенсора, которые будут расположены слева, справа, сверху и снизу от центра. Важно отметить, что для кругов в конфигурации задано свойство isSensor: true. Это означает, что эти тела будут генерировать события столкновения, но не оказывать физического сопротивления (не отталкивать объекты). Каждому сенсору присваивается уникальная метка (label) для последующей идентификации.

const Bodies = Phaser.Physics.Matter.Matter.Bodies;

const rect = Bodies.rectangle(0, 0, 98, 98);
const circleA = Bodies.circle(-70, 0, 24, { isSensor: true, label: 'left' });
const circleB = Bodies.circle(70, 0, 24, { isSensor: true, label: 'right' });
const circleC = Bodies.circle(0, -70, 24, { isSensor: true, label: 'top' });
const circleD = Bodies.circle(0, 70, 24, { isSensor: true, label: 'bottom' });

На следующем шаге все эти части объединяются в одно физическое тело с помощью Phaser.Physics.Matter.Matter.Body.create. Параметр parts принимает массив созданных тел, а inertia: Infinity делает тело бесконечно инертным, что упрощает управление им с клавиатуры без влияния вращающего момента.

const compoundBody = Phaser.Physics.Matter.Matter.Body.create({
    parts: [ rect, circleA, circleB, circleC, circleD ],
    inertia: Infinity
});

Связывание тела со спрайтом и настройка мира

После создания физического тела его нужно связать с визуальным спрайтом в Phaser. Создаётся спрайт игрока (this.player) с помощью this.matter.add.image. Изначально ему назначается стандартное тело, но метод setExistingBody() заменяет его на наше составное тело.

this.player = this.matter.add.image(0, 0, 'block');
this.player.setExistingBody(compoundBody);
this.player.setPosition(100, 300);

Также на сцене размещаются три статичных блока, которые будут служить мишенями для сенсоров. Статичность задаётся методом .setStatic(true), что означает, что эти тела не будут двигаться под воздействием физики.

const testA = this.matter.add.image(400, 150, 'block').setStatic(true);
const testB = this.matter.add.image(600, 450, 'block').setStatic(true);
const testC = this.matter.add.image(200, 550, 'block').setStatic(true);

Конфигурация физического мира Matter задана в объекте config при создании игры. Здесь отключена гравитация (gravity: { y: 0 }), чтобы игрок не падал, и включён режим отладки (debug: true), который визуализирует физические тела и сенсоры.

Обработка событий столкновения сенсоров

Сердце примера — обработчик события collisionstart, который срабатывает при начале столкновения любых двух тел в мире Matter. Это событие вешается на this.matter.world.

this.matter.world.on('collisionstart', event => {
    const pairs = event.pairs;
    for (let i = 0; i < pairs.length; i++) {
        // ... логика обработки
    }
});

Внутри цикла мы перебираем все пары столкнувшихся тел (pairs). Нас интересуют только те столкновения, где одно из тел является сенсором (проверка pairs[i].isSensor).

Далее мы определяем, какое именно тело в паре — сенсор, а какое — статичный блок. Это нужно, потому что порядок тел bodyA и bodyB в паре не определён.

if (pairs[i].isSensor) {
    let blockBody;
    let playerBody;

    if (bodyA.isSensor) {
        blockBody = bodyB;
        playerBody = bodyA;
    } else if (bodyB.isSensor) {
        blockBody = bodyA;
        playerBody = bodyB;
    }
    // ...
}

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

const playerSprite = playerBody.gameObject;
const blockSprite = blockBody.gameObject;

Идентификация сенсора и визуальная обратная связь

Получив тело сенсора (playerBody), мы можем прочитать его метку (label), которую задали при создании. В зависимости от значения метки, мы выбираем цвет для окрашивания блока.

let color;
if (playerBody.label === 'left') {
    color = 0xff0000;
} else if (playerBody.label === 'right') {
    color = 0x00ff00;
} else if (playerBody.label === 'top') {
    color = 0x0000ff;
} else if (playerBody.label === 'bottom') {
    color = 0xffff00;
}

Затем статичный блок окрашивается в выбранный цвет с помощью метода setTint(). Параметр Phaser.TintModes.FILL в setTintMode() гарантирует, что цвет будет применён равномерно по всей площади спрайта.

blockSprite.setTint(color).setTintMode(Phaser.TintModes.FILL);

Таким образом, каждый блок будет менять цвет в зависимости от того, каким именно сенсором (левым, правым, верхним или нижним) игрок его коснулся. Это наглядно демонстрирует, какая именно зона взаимодействия сработала.

Управление и игровая логика

Управление игроком реализовано в методе update(), который вызывается на каждом кадре. Состояние клавиш-стрелок проверяется через объект this.cursors. В зависимости от нажатой клавиши, игроку задаётся скорость по оси X или Y с помощью setVelocityX() и setVelocityY().

if (this.cursors.left.isDown) {
    this.player.setVelocityX(-10);
} else if (this.cursors.right.isDown) {
    this.player.setVelocityX(10);
} else {
    this.player.setVelocityX(0);
}
// ... аналогично для up/down

Поскольку у составного тела установлена бесконечная инерция (inertia: Infinity), применение скорости приводит к прямолинейному движению без вращения. Это типичное поведение для персонажа, управляемого с клавиатуры в топ-даун или платформерном стиле.

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

Составные тела и сенсоры Matter.js открывают перед разработчиком игр на Phaser широкие возможности для создания сложных и точных механик взаимодействия. Вы можете использовать этот подход не только для детектирования сторон столкновения, но и для создания сложных хитбоксов у персонажей (например, отдельные сенсоры для ударов руками и ногами), триггерных зон вокруг объектов или невидимых контактных датчиков. Для экспериментов попробуйте: изменить форму и количество сенсоров, сделать сенсоры разного размера, добавить логику не только на начало (collisionstart), но и на окончание (collisionend) столкновения, чтобы убирать tint, или использовать сенсоры для сбора предметов, активации ловушек или открытия дверей.