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

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

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

Живой запуск

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

Исходный код


class Bullet extends Phaser.Physics.Arcade.Sprite
{
    constructor (scene, x, y)
    {
        super(scene, x, y, 'bullet');
    }

    fire (x, y)
    {
        this.body.reset(x, y);

        this.setActive(true);
        this.setVisible(true);

        this.setVelocityY(-600);
    }

    kill ()
    {
        this.setActive(false);
        this.setVisible(false);
    }

    preUpdate (time, delta)
    {
        super.preUpdate(time, delta);

        if (this.y <= -32)
        {
            this.kill();
        }
    }
}

class Bullets extends Phaser.Physics.Arcade.Group
{
    constructor (scene)
    {
        super(scene.physics.world, scene);

        this.createMultiple({
            frameQuantity: 30,
            key: 'bullet',
            active: false,
            visible: false,
            classType: Bullet
        });
    }

    fireBullet (x, y)
    {
        let bullet = this.getFirstDead(false);

        if (bullet)
        {
            bullet.fire(x, y);
        }
    }
}

class Example extends Phaser.Scene
{
    constructor ()
    {
        super();

        this.bullets;
        this.emitter;
        this.explode;
    }

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('bg', 'assets/skies/space3.png');
        this.load.image('bullet', 'assets/sprites/bullets/bullet7.png');
        this.load.image('ship', 'assets/sprites/bsquadron1.png');
        this.load.atlas('bubbles', 'assets/particles/bubbles.png', 'assets/particles/bubbles.json');
    }

    create ()
    {
        this.add.image(400, 300, 'bg');

        this.emitter = this.add.particles(0, 0, 'bubbles', {
            x: 100,
            y: 30,
            frame: [ 'bluebubble', 'redbubble', 'greenbubble', 'silverbubble' ],
            scale: { min: 0.25, max: 1 },
            rotate: { start: 0, end: 360 },
            speed: { min: 50, max: 100 },
            lifespan: 6000,
            frequency: 100,
            blendMode: 'ADD',
            gravityY: 110
        });

        this.explode = this.add.particles(0, 0, 'bubbles', {
            frame: 'elec1',
            angle: { start: 0, end: 360, steps: 32 },
            lifespan: 1500,
            speed: 400,
            quantity: 32,
            scale: { start: 0.5, end: 0 },
            emitting: false
        });

        this.tweens.add({
            targets: this.emitter,
            particleX: 700,
            yoyo: true,
            repeat: -1,
            ease: 'sine.inout',
            duration: 1500
        });

        this.bullets = new Bullets(this);

        const ship = this.add.image(400, 550, 'ship');

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

            ship.x = pointer.worldX;

        });

        this.input.on('pointerdown', (pointer) => {

            this.bullets.fireBullet(ship.x, ship.y);

        });

        this.add.text(16, 16, 'Click to shoot');
    }

    update ()
    {
        const bullets = this.bullets.getChildren();

        bullets.forEach(bullet => {

            if (bullet.active)
            {
                const particles = this.emitter.overlap(bullet.body);

                if (particles.length > 0)
                {
                    particles.forEach(particle => {

                        this.explode.emitParticleAt(particle.x, particle.y);

                        particle.kill();

                    });

                    bullet.kill();
                }
            }

        });
    }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#2d2d88',
    parent: 'phaser-example',
    physics: {
        default: 'arcade',
        arcade: {
            debug: false,
            gravity: { y: 0 }
        }
    },
    scene: Example
};

const game = new Phaser.Game(config);

Архитектура пула пуль: переиспользование объектов

Создавать и уничтожать игровые объекты каждый кадр — дорого. Phaser предлагает использовать группы (Group) с пулом. В примере создан класс Bullets, наследующий Phaser.Physics.Arcade.Group. Он заранее создает 30 пуль (frameQuantity: 30), делая их неактивными и невидимыми. Это пул (object pool).

Класс Bullet расширяет Phaser.Physics.Arcade.Sprite. Его метод fire() не создает новый спрайт, а «реанимирует» существующий из пула: сбрасывает позицию тела, делает активным/видимым и задает скорость. Метод kill() возвращает пулю в пул, деактивируя ее.

class Bullets extends Phaser.Physics.Arcade.Group {
    constructor (scene) {
        super(scene.physics.world, scene);
        this.createMultiple({
            frameQuantity: 30,
            key: 'bullet',
            active: false,
            visible: false,
            classType: Bullet
        });
    }
    fireBullet (x, y) {
        let bullet = this.getFirstDead(false);
        if (bullet) {
            bullet.fire(x, y);
        }
    }
}

Создание эмиттеров частиц: фон и взрыв

В сцене создаются два эмиттера частиц с разным назначением. Первый, this.emitter, — это непрерывный фон из пузырьков. Он использует атлас bubbles с несколькими кадрами (frame: [ 'bluebubble', ... ]). Частицы имеют случайный размер, вращение, скорость и живут 6 секунд. Ключевой параметр gravityY: 110 заставляет их падать вниз, создавая эффект подъема. Эмиттер анимирован с помощью твина, который двигает точку испускания (particleX) по горизонтали.

Второй эмиттер, this.explode, предназначен для разового взрыва. Он испускает частицы кадра elec1 по всем направлениям (angle: ... steps: 32). Параметр emitting: false означает, что он не работает постоянно, а будет запущен вручную при столкновении.

this.emitter = this.add.particles(0, 0, 'bubbles', {
    x: 100,
    y: 30,
    frame: [ 'bluebubble', 'redbubble', 'greenbubble', 'silverbubble' ],
    scale: { min: 0.25, max: 1 },
    rotate: { start: 0, end: 360 },
    speed: { min: 50, max: 100 },
    lifespan: 6000,
    frequency: 100,
    blendMode: 'ADD',
    gravityY: 110
});

this.explode = this.add.particles(0, 0, 'bubbles', {
    frame: 'elec1',
    angle: { start: 0, end: 360, steps: 32 },
    lifespan: 1500,
    speed: 400,
    quantity: 32,
    scale: { start: 0.5, end: 0 },
    emitting: false
});

Ядро механики: метод overlap() для эмиттера

Главный интерес представляет метод overlap() экземпляра эмиттера частиц. В отличие от физического движка Arcade, который проверяет столкновения тел, эмиттер может сам определить, пересекаются ли его живые частицы с заданным прямоугольником (в данном случае — bullet.body).

В методе update() сцены для каждой активной пули вызывается this.emitter.overlap(bullet.body). Метод возвращает массив объектов частиц (particles), чьи хитбоксы в данный момент пересекаются с телом пули. Если массив не пуст, для каждой задетой частицы запускается цепная реакция.

const particles = this.emitter.overlap(bullet.body);
if (particles.length > 0) {
    particles.forEach(particle => {
        this.explode.emitParticleAt(particle.x, particle.y);
        particle.kill();
    });
    bullet.kill();
}

Здесь важно: particle — это не спрайт, а внутренний объект эмиттера. У него есть свойства `xиy. Вызовparticle.kill()немедленно уничтожает эту частицу фонового эмиттера. Одновременно в этой же позиции испускается частица из эмиттера взрыва (this.explode.emitParticleAt). Пуля также уничтожается (bullet.kill()`), возвращаясь в пул.

Управление вводом и жизненным циклом пуль

Игрок управляет кораблем, перемещая его по горизонтали курсором мыши (pointermove). Выстрел происходит по клику (pointerdown). Координаты корабля передаются в bullets.fireBullet(), которая запрашивает первую «мертвую» пулю из пула и «выстреливает» ее.

Каждая пуля в методе preUpdate() проверяет, не вышла ли она за верхнюю границу экрана (this.y <= -32). Если да, она автоматически уничтожается через this.kill(). Это предотвращает накопление невидимых пуль за пределами экрана и возвращает их в пул для повторного использования.

preUpdate (time, delta) {
    super.preUpdate(time, delta);
    if (this.y <= -32) {
        this.kill();
    }
}

// В сцене:
this.input.on('pointerdown', (pointer) => {
    this.bullets.fireBullet(ship.x, ship.y);
});

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

Пример наглядно показывает, как превратить чисто визуальные эффекты в интерактивные игровые элементы. Метод overlap() эмиттера — мощный инструмент для детектирования взаимодействий с частицами без привлечения тяжелого физического движка для каждой из них. Для экспериментов попробуйте: 1. Изменить форму хитбокса пули, используя bullet.body.setSize() и setOffset(), чтобы сделать столкновения более точными или, наоборот, щедрыми. 2. Добавить звуковой эффект при взрыве частицы, вызывая this.sound.play() внутри цикла particles.forEach. 3. Сделать эмиттер explode цепным: чтобы каждая взорвавшаяся частица тоже могла детонировать другие, добавив рекурсивную проверку. 4. Заменить пули на другой тип снаряда (например, луч) и проверить работу overlap() с вытянутым прямоугольником тела.