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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    lastFired = 0;
    cursors;
    stats;
    speed;
    info;
    ship;
    bullets;

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

    create ()
    {
        //  A sample custom class with its own 'update' and 'fire' methods
        class Bullet extends Phaser.GameObjects.Image
        {
            constructor (scene)
            {
                super(scene, 0, 0, 'bullet');

                this.speed = Phaser.Math.GetSpeed(500, 1);
            }

            fire (x, y)
            {
                this.setPosition(x, y - 50);

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

            update (time, delta)
            {
                this.y -= this.speed * delta;

                if (this.y < -50)
                {
                    this.setActive(false);
                    this.setVisible(false);
                }
            }
        }

        this.info = this.add.text(0, 0, 'Click to add objects', { fill: '#00ff00' });

        //  Set the custom class type that this Group will create.
        //  Limited to just 10 objects in the pool, not allowed to grow beyond it.
        //  runChildUpdate tells the Group to call 'update' on any active child. The default is false.

        this.bullets = this.add.group({
            classType: Bullet,
            maxSize: 10,
            runChildUpdate: true
        });

        this.ship = this.add.sprite(400, 500, 'ship').setDepth(1);

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

        this.speed = Phaser.Math.GetSpeed(300, 1);
    }

    update (time, delta)
    {
        if (this.cursors.left.isDown)
        {
            this.ship.x -= this.speed * delta;
        }
        else if (this.cursors.right.isDown)
        {
            this.ship.x += this.speed * delta;
        }

        if (this.cursors.up.isDown && time > this.lastFired)
        {
            const bullet = this.bullets.get();
            
            if (bullet)
            {
                bullet.fire(this.ship.x, this.ship.y);

                this.lastFired = time + 50;
            }
        }

        this.info.setText([
            `Used: ${this.bullets.getTotalUsed()}`,
            `Free: ${this.bullets.getTotalFree()}`
        ]);
    }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#2d2d2d',
    parent: 'phaser-example',
    scene: Example
};

const game = new Phaser.Game(config);

Создание кастомного класса для пули

Вместо использования стандартного Phaser.GameObjects.Image мы создаём собственный класс Bullet, который наследует его свойства. Это даёт нам полный контроль над логикой поведения каждого объекта.

Ключевые моменты в конструкторе: - super(scene, 0, 0, 'bullet') вызывает конструктор родительского класса, создавая изображение с ключом 'bullet' в координатах (0,0). Эти начальные координаты не важны, так как пуля будет перемещаться. - this.speed = Phaser.Math.GetSpeed(500, 1) задаёт скорость движения. Функция GetSpeed конвертирует пиксели в секунду (500) в значение, которое можно умножить на delta для плавного движения, не зависящего от частоты кадров.

Метод fire активирует пулю, устанавливая её начальную позицию чуть выше корабля. Метод update отвечает за движение и деактивацию пули, когда она улетает за пределы экрана.

class Bullet extends Phaser.GameObjects.Image
{
    constructor (scene)
    {
        super(scene, 0, 0, 'bullet');
        this.speed = Phaser.Math.GetSpeed(500, 1);
    }

    fire (x, y)
    {
        this.setPosition(x, y - 50);
        this.setActive(true);
        this.setVisible(true);
    }

    update (time, delta)
    {
        this.y -= this.speed * delta;
        if (this.y < -50)
        {
            this.setActive(false);
            this.setVisible(false);
        }
    }
}

Настройка пула объектов с помощью Group

Phaser предоставляет мощный инструмент для управления пулами — this.add.group(). Группа может создавать, хранить и переиспользовать объекты заданного класса.

Конфигурация группы this.bullets задаёт три важных параметра: 1. classType: Bullet — указывает, объекты какого класса будет создавать и хранить группа. 2. maxSize: 10 — жёстко ограничивает максимальный размер пула. Группа не создаст больше 10 пуль, даже если запросов больше. 3. runChildUpdate: true — это самый важный флаг. Он заставляет группу автоматически вызывать метод update у каждого активного дочернего объекта в основном игровом цикле. Без этого наши пули не будут двигаться.

this.bullets = this.add.group({
    classType: Bullet,
    maxSize: 10,
    runChildUpdate: true
});

Логика стрельбы и переиспользование объектов

В методе update сцены обрабатывается ввод игрока и осуществляется "выстрел". Механика пула раскрывается в строке const bullet = this.bullets.get().

Метод this.bullets.get() — это сердце пула. Он работает так: - Ищет первый неактивный (available) объект Bullet внутри группы. - Если такой объект найден, он возвращает его для повторного использования. - Если все 10 пуль активны (летят по экрану), метод вернёт null или undefined, и новый выстрел не произойдёт, пока одна из пуль не деактивируется.

Получив объект, мы вызываем его метод fire, который сбрасывает позицию и делает пулю видимой и активной. Ограничение this.lastFired создаёт задержку между выстрелами.

if (this.cursors.up.isDown && time > this.lastFired)
{
    const bullet = this.bullets.get();
    if (bullet)
    {
        bullet.fire(this.ship.x, this.ship.y);
        this.lastFired = time + 50;
    }
}

Мониторинг состояния пула

Для отладки и понимания работы системы полезно видеть, сколько объектов занято и сколько свободно. Группа предоставляет методы для этого.

- `this.bullets.getTotalUsed()` возвращает количество активных (used) объектов в группе — тех, у которых `active=true`.
- `this.bullets.getTotalFree()` возвращает количество неактивных (free) объектов, готовых к переиспользованию.

Эти значения выводятся на экран с помощью объекта this.info типа Phaser.GameObjects.Text. Наблюдая за этими числами, можно убедиться, что пул работает корректно и объекты переиспользуются, а не создаются заново.

this.info.setText([
    `Used: ${this.bullets.getTotalUsed()}`,
    `Free: ${this.bullets.getTotalFree()}`
]);

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

Использование пула объектов с Phaser.Group — это эффективный и элегантный способ управления часто создаваемыми и уничтожаемыми игровыми сущностями, такими как пули, частицы или враги. Вы не только избегаете проблем с производительностью, но и получаете удобный централизованный интерфейс для управления коллекцией объектов. **Идеи для экспериментов:** 1. Измените логику деактивации пули на столкновение с целью, используя this.physics.add.overlap. 2. Создайте разнообразные пулы для разных типов снарядов (ракеты, лазеры), каждый со своим кастомным классом. 3. Реализуйте "перегрев" оружия, когда при активных всех 10 пулях игрок не может стрелять, пока одна не освободится.