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

Маскирование — мощный инструмент для создания визуальных эффектов в играх. Обычно маски — это статические изображения или геометрические фигуры. Но что, если использовать динамический контейнер с вложенными объектами в качестве маски? Этот пример из официальной галереи Phaser демонстрирует, как создать интерактивный визуальный эффект, где сложная иерархия вращающихся контейнеров служит маской для спрайта. Вы научитесь строить рекурсивные структуры объектов, применять их как фильтры и управлять ими в реальном времени.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    containerTails = [];
    containers = [];

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');

        this.load.image('backdrop', 'assets/pics/platformer-backdrop.png');
        this.load.image('arrow', 'assets/sprites/arrow.png');
        this.load.image('mask', 'assets/pics/mask.png');
        this.load.image('bunny', 'assets/sprites/bunny.png');
    }

    create ()
    {
        const backdrop = this.make.image({
            x: game.config.width / 2,
            y: game.config.height / 2,
            key: 'backdrop',
            add: true
        }).setScale(3);

        const maskImage = this.make.image({
            x: game.config.width / 2,
            y: game.config.height / 2,
            key: 'mask',
            add: false
        }).setScale(2);
        let lastContainer;
        const count = 40;

        const bunny = this.make.sprite({
            x: game.config.width / 2,
            y: game.config.height / 2,
            key: 'bunny',
            add: true
        });


        this.rootContainer = this.make.container({x: game.config.width / 2, y: game.config.height / 2, add: false });
        bunny.enableFilters().filters.external.addMask(this.rootContainer);


        for (let j = 0; j < 4; ++j)
        {
            for (let index = 0; index < count; ++index)
            {
                const image = this.make.image({x: 0, y: 0, key: 'arrow', add: false});
                if (index === 0)
                {
                    lastContainer = this.make.container({x: 0, y: 0, add: false});
                    this.containers.push(lastContainer);
                    lastContainer.rotation += (j * 90) * Math.PI / 180;
                    this.rootContainer.add(lastContainer);
                }
                else
                {
                    let newContainer = this.make.container({x: image.width, y: 0, add: false});
                    lastContainer.add(newContainer);
                    lastContainer = newContainer;
                    newContainer.setScale(1.0 - index / (count));
                    newContainer.rotation = index / count * 2;
                }
                image.setOrigin(0, 0.5);
                lastContainer.add(image);

                if (index === 5 || index === 4 || index === 10)
                {
                    let leafContainer = lastContainer;
                    const direction = index === 5 ? 1 : -1;
                    for (let k = 0; k < 10; ++k)
                    {
                        let image2 = this.make.image({x: 0, y: 0, key: 'arrow', add: false});
                        let newContainer = this.make.container({x: image2.width, y: 0, add: false});
                        leafContainer.add(newContainer);
                        leafContainer = newContainer;
                        leafContainer.setScale(1.0 - k / 10);
                        leafContainer.rotation = 0.1 * direction;
                        image2.setOrigin(0, 0.5);
                        leafContainer.add(image2);
                    }
                }

                if (index === count - 1) { this.containerTails.push(lastContainer); }
            }
        }

        let move = false;

        this.input.on('pointerdown', pointer =>
        {
            move = true;
        });
        this.input.on('pointerup', pointer =>
        {
            move = false;
        });

        this.input.on('pointermove', pointer =>
        {

            if (move)
            {
                bunny.x = pointer.x;
                bunny.y = pointer.y;
            }

        });

    }

    update ()
    {
        for (let index = 0; index < this.containerTails.length; ++index)
        {
            const container = this.containerTails[index];
            this.rotateContainer(container, 0.01);
        }
        this.rootContainer.rotation += 0.01;
    }

    rotateContainer (container, rotation)
    {
        if (container)
        {
            container.rotation += rotation;
            this.rotateContainer(container.parentContainer, rotation);
        }
    }
}

const config = {
    type: Phaser.WEBGL,
    parent: 'phaser-example',
    scene: Example
};

const game = new Phaser.Game(config);

Подготовка сцены и создание основы для маски

В методе create() загружается фон и спрайты. Ключевой момент — создание главного контейнера rootContainer, который не добавляется на сцену напрямую, а используется как маска. Спрайт кролика (bunny) получает фильтр, к которому добавляется эта маска с помощью filters.external.addMask(). Это означает, что видимая область кролика будет ограничена содержимым контейнера и всех его дочерних элементов.

this.rootContainer = this.make.container({x: game.config.width / 2, y: game.config.height / 2, add: false });
bunny.enableFilters().filters.external.addMask(this.rootContainer);

Рекурсивное построение иерархии контейнеров

Далее в двойном цикле создаётся сложная древовидная структура из контейнеров и изображений стрелок. Для каждого из четырёх "лепестков" (цикл `j) создаётся цепочка из 40 контейнеров (циклindex). Первый контейнер в цепочке (lastContainer) поворачивается на 90 градусов относительно предыдущего лепестка и добавляется вrootContainer`. Каждый последующий контейнер добавляется как дочерний к предыдущему, создавая цепь. Масштаб и поворот каждого нового контейнера меняются в зависимости от его позиции в цепочке.

let newContainer = this.make.container({x: image.width, y: 0, add: false});
lastContainer.add(newContainer);
lastContainer = newContainer;
newContainer.setScale(1.0 - index / (count));
ewContainer.rotation = index / count * 2;

Добавление боковых ответвлений

Для придания структуре большей сложности на определённых шагах основной цепочки (индексы 4, 5 и 10) создаются боковые ветви. Это ещё один уровень вложенности: от текущего контейнера (leafContainer) создаётся цепочка из 10 дочерних контейнеров. Направление их поворота (direction) зависит от индекса. Таким образом, простая цепь превращается в фракталоподобное дерево.

const direction = index === 5 ? 1 : -1;
for (let k = 0; k < 10; ++k)
{
    let newContainer = this.make.container({x: image2.width, y: 0, add: false});
    leafContainer.add(newContainer);
    leafContainer = newContainer;
    leafContainer.rotation = 0.1 * direction;
}

Интерактивность и управление маской

Пользователь может перетаскивать спрайт кролика по сцене с помощью мыши или касания. Обработчики событий pointerdown, pointerup и pointermove управляют флагом move и обновляют координаты bunny.x и bunny.y. Поскольку маска (контейнерная иерархия) закреплена в центре сцены, а кролик движется, видимая часть кролика, ограниченная маской, создаёт эффект "подсветки" сложной движущейся структурой.

this.input.on('pointermove', pointer =>
{
    if (move)
    {
        bunny.x = pointer.x;
        bunny.y = pointer.y;
    }
});

Рекурсивная анимация структуры

В методе update() происходит анимация всей конструкции. Во-первых, вращается главный контейнер: this.rootContainer.rotation += 0.01. Во-вторых, для каждого из четырёх конечных (хвостовых) контейнеров, сохранённых в массиве containerTails, вызывается функция rotateContainer. Эта функция рекурсивно применяет поворот не только к самому контейнеру, но и ко всем его родителям вплоть до корня. Это заставляет всю ветвь от конца до rootContainer плавно изгибаться, создавая живую, органичную анимацию маски.

rotateContainer (container, rotation)
{
    if (container)
    {
        container.rotation += rotation;
        this.rotateContainer(container.parentContainer, rotation);
    }
}

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

Этот пример блестяще демонстрирует, что в Phaser маской может быть любой объект, включая динамические контейнеры с глубокой вложенностью. Вы можете экспериментировать: замените спрайт стрелки на частицы или анимированные текстуры, измените логику построения дерева для создания других фрактальных форм или используйте несколько спрайтов с разными масками-контейнерами для многослойных эффектов. Рекурсивное вращение — лишь один из способов анимации; попробуйте менять масштаб или позицию дочерних элементов на основе времени или физики.