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

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

Версия 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');
    }

    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;

        this.rootContainer = this.add.container(game.config.width / 2, game.config.height / 2);
        this.rootContainer.enableFilters().filters.external.addMask(maskImage);

        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)
                    {
                        const 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)
            {
                maskImage.x = pointer.x;
                maskImage.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);

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

В методе preload загружаются необходимые изображения: фон, спрайт стрелки и изображение-маска. Ключевой момент происходит в create. Сначала создается фоновое изображение и масштабируется.

Затем создается само изображение маски (maskImage). Обратите внимание на параметр add: false — объект создается, но не добавляется автоматически на дисплей-лист сцены. Это важно, так как маска будет использоваться как источник для фильтра, а не как видимый объект.

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

Далее создается корневой контейнер rootContainer в центре экрана. Для него активируется система фильтров методом enableFilters(), и к его внешним фильтрам (filters.external) добавляется созданная маска методом addMask(maskImage). Теперь все содержимое этого контейнера будет обрезано по форме изображения маски.

Построение иерархической цепочки контейнеров

Код создает четыре основные "ветки", повернутые относительно друг друга на 90 градусов. Каждая ветка — это цепочка из 40 вложенных друг в друга контейнеров (Container), в каждый из которых добавлен спрайт стрелки.

Первый контейнер в цепочке создается отдельно, поворачивается на угол, кратный 90°, и добавляется в rootContainer. Каждый последующий контейнер создается с небольшим смещением по X (равным ширине спрайта стрелки) и добавляется как дочерний к предыдущему (lastContainer.add(newContainer)). Это формирует связанную иерархию.

let newContainer = this.make.container({x: image.width, y: 0, add: false});
lastContainer.add(newContainer);
lastContainer = newContainer;

С каждым новым звеном масштаб контейнера уменьшается (setScale(1.0 - index / count)), а угол поворота немного увеличивается. Спрайт стрелки внутри контейнера устанавливает точку вращения (setOrigin(0, 0.5)) у своего левого края, что заставляет всю цепочку изгибаться.

На определенных шагах (индексах 4, 5, 10) от основного ствола "ветки" создаются дополнительные боковые ответвления по тому же принципу вложенности, но с другим направлением вращения.

Динамическое управление маской с помощью ввода

Одна из сильных сторон примера — интерактивность. Положение маски привязано к курсору мыши. Для этого отслеживаются события ввода.

Объявляется флаг move. При нажатии кнопки мыши (pointerdown) флаг становится true, при отпускании (pointerup) — false. В обработчике события перемещения мыши (pointermove) проверяется этот флаг. Если движение активно (move равно true), координаты `xиyизображения маски (maskImage`) обновляются координатами указателя.

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

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

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

В методе update обеспечивается постоянная анимация. Во-первых, вращается сам корневой контейнер: this.rootContainer.rotation += 0.01.

Во-вторых, вращаются "хвосты" каждой из четырех основных веток — последние контейнеры в каждой цепочке, ссылки на которые сохранены в массиве containerTails. Для их вращения используется функция rotateContainer.

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

Эта функция рекурсивна. Она принимает контейнер и угол поворота. Сначала она увеличивает вращение переданного контейнера. Затем вызывает саму себя для родительского контейнера (container.parentContainer). Таким образом, поворот "хвоста" вызывает последовательный поворот всех его предков в цепочке вплоть до корня. Это создает плавную волнообразную анимацию всей структуры, где движение начинается с конца каждой ветки и распространяется к основанию.

Итог работы примера и ключевые выводы

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

1. **Иерархия контейнеров:** Создание сложных составных объектов через вложение Container. 2. **Маскирование через фильтры:** Применение маски ко всему содержимому контейнера и его потомкам с помощью filters.external.addMask(). 3. **Рекурсивная трансформация:** Управление свойствами (вроде rotation) всей цепочки объектов через рекурсивный обход иерархии. 4. **Интерактивность:** Динамическое обновление свойств маски в ответ на действия пользователя.

Код эффективно использует Display List. Хотя в сцене физически находится всего один корневой контейнер, его внутренняя иерархия и маскирование создают иллюзию сложной независимой анимации.

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

Маски для контейнеров открывают широкие возможности для визуального дизайна: от простого скругления углов панелей интерфейса до сложных динамических эффектов раскрытия или подсветки. Поэкспериментируйте: замените статическое изображение маски на генерируемую графику (например, this.make.graphics), чтобы рисовать маски произвольной формы в реальном времени. Попробуйте анимировать не только положение, но и масштаб или угол поворота маски. Комбинируйте маскирование с другими фильтрами (размытие, цветокоррекция) для создания по-настоящему уникальных визуальных стилей для ваших игровых меню, кат-сцен или спецэффектов.