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

При создании аркадных игр с множеством движущихся объектов (пули, враги, частицы) стандартный подход — создавать `Physics Sprite` для каждого — быстро приводит к проблемам с производительностью. Каждый такой спрайт несёт накладные расходы на создание и сборку мусора. В этой статье разберём продвинутую технику из официальных примеров Phaser: создание пула переиспользуемых физических тел (`Arcade.Body`), которые можно привязывать к лёгким графическим объектам (`Blitter` и `Bob`). Этот метод позволяет создавать сотни и тысячи физических объектов с минимальным потреблением памяти и без "тормозов", что критично для динамичных шутеров или игр с большим количеством частиц.

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

Живой запуск

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

Исходный код


class PhysicsBob extends Phaser.GameObjects.Bob
{
    constructor (blitter, x, y, frame, visible, body)
    {
        super(blitter, x, y, frame, visible);

        this.body = body;

        //  The physics body needs access to the follow properties,
        //  which a Blittle Bob doesn't have by default, so we add them here:
        this.scaleX = 1;
        this.scaleY = 1;
        this.angle = 0;
        this.rotation = 0;
        this.originX = 0;
        this.originY = 0;
        this.displayOriginX = 0;
        this.displayOriginY = 0;
        this.width = this.frame.realWidth;
        this.height = this.frame.realHeight;
    }
}

class Example extends Phaser.Scene
{
    active;
    blitter;
    pool;
    textureFrames;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('player', 'assets/sprites/ship.png');
        this.load.atlas('atlas', 'assets/atlas/megaset-0.png', 'assets/atlas/megaset-0.json');
    }

    create ()
    {
        this.textureFrames = [ '32x32', 'aqua_ball', 'asteroids_ship', 'block', 'blue_ball', 'bsquadron1', 'carrot', 'eggplant', 'flectrum', 'gem', 'ilkke', 'maggot', 'melon', 'mushroom', 'onion', 'orb-blue', 'orb-green', 'orb-red', 'pepper', 'phaser1', 'pineapple', 'slime', 'space-baddie', 'spinObj_01', 'spinObj_02', 'spinObj_03', 'splat' ];

        this.blitter = this.add.blitter(0, 0, 'atlas');

        const player = this.physics.add.image(400, 500, 'player');

        //  This array contains all the enemy sprites which are currently active and moving
        this.active = [];

        //  Create a pool of 100 physics bodies.

        //  To achieve this, we need a dummy Game Object they can pull default values from.

        this.pool = [];

        const dummy = this.add.image();
        const { world } = this.physics;

        for (let i = 0; i < 100; i++)
        {
            const body = new Phaser.Physics.Arcade.Body(world, dummy);

            this.pool.push(body);
        }

        //  Every 100ms we'll release an enemy
        this.time.addEvent({ delay: 100, callback: () => { this.releaseEnemy(); }, loop: true });

        //  Let the player move the ship with the mouse
        this.input.on('pointermove', (pointer) =>
        {
            player.x = pointer.worldX;
            player.y = pointer.worldY;
        });
    }

    update ()
    {
        this.checkEnemyBounds();
    }

    checkEnemyBounds ()
    {
        const { world } = this.physics;

        //  Check which enemies have left the screen
        for (let i = this.active.length - 1; i >= 0; i--)
        {
            const enemy = this.active[i];

            if (enemy.y > 600 + enemy.height)
            {
                //  Recycle this body
                const { body } = enemy;

                //  Remove it from the internal world trees
                world.disableBody(body);

                //  Clear the gameObject references
                body.gameObject = undefined;

                //  Put it back into the pool
                this.pool.push(body);

                //  Nuke the sprite
                enemy.body = undefined;
                enemy.destroy();

                //  Remove it from the active array
                this.active.splice(i, 1);

                //  ^^^ technically, you wouldn't ever destroy the sprite, but instead
                //  put them back into their own pools for later re-use, otherwise there's no
                //  point recycling the physics bodies!
            }
        }
    }

    releaseEnemy ()
    {
        const { pool } = this;

        const body = pool.pop();

        const x = Phaser.Math.Between(0, 800);
        const y = Phaser.Math.Between(-1200, -300);
        const frame = this.blitter.texture.get(
            Phaser.Utils.Array.GetRandom(this.textureFrames)
        );

        const enemy = new PhysicsBob(this.blitter, x, y, frame, true, body);

        this.blitter.children.add(enemy);

        this.blitter.dirty = true;

        //  Link the sprite to the body - this property doesn't exist
        body.gameObject = enemy;

        //  We need to do this to give the body the frame size of the sprite
        body.setSize();

        //  Insert the body back into the physics world
        this.physics.world.add(body);

        //  Give it some velocity
        body.setVelocity(0, Phaser.Math.Between(200, 500));

        //  Add to our active pool
        this.active.push(enemy);
    }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    parent: 'phaser-example',
    physics: {
        default: 'arcade',
        arcade: {
            debug: true,
            debugShowVelocity: false
        }
    },
    scene: Example
};

const game = new Phaser.Game(config);

Суть проблемы и идея решения

Обычно для создания объекта с физикой в Arcade используется фабричный метод this.physics.add.image(). Под капотом он делает несколько вещей: создаёт спрайт (Image), создаёт для него физическое тело (Arcade.Body) и связывает их. Если таких объектов нужно много (например, пули в шутере), постоянное создание и уничтожение этих связок нагружает сборщик мусора и приводит к просадкам FPS.

Идея оптимизации заключается в разделении ответственности: 1. **Пул физических тел (Body)**: мы создаём их один раз в нужном количестве и переиспользуем. Когда объект (например, враг) улетает за экран, мы не удаляем его тело, а возвращаем в пул для следующего врага. 2. **Лёгкая графика (Blitter/Bob)**: для отрисовки вместо тяжёлых Sprite или Image используем систему Blitter. Она предназначена для быстрого вывода множества простых изображений (текстурных фреймов) и управляется через объекты Bob, которые гораздо легче.

Ключевой шаг — вручную связать переиспользуемое тело из пула с новым Bob для отрисовки.

Создание пула физических тел

В методе create() сцены мы подготавливаем наш арсенал — массив из 100 физических тел. Так как конструктор Arcade.Body требует игровой объект в качестве источника для начальных значений (позиция, размер), мы создаём временный «пустышка»-объект (dummy).

this.pool = [];
const dummy = this.add.image();
const { world } = this.physics;

for (let i = 0; i < 100; i++)
{
    const body = new Phaser.Physics.Arcade.Body(world, dummy);
    this.pool.push(body);
}

Важный момент: на этом этапе тела созданы, но **не добавлены** в физический мир (world). Они находятся в нашем резерве. Метод world.disableBody(body) позже будет использоваться для обратного извлечения тела из мира без его уничтожения.

Кастомный Bob для связи с физикой

Стандартный Phaser.GameObjects.Bob не содержит свойств, которые ожидает от игрового объекта Arcade.Body (например, scale, angle, width). Нам нужен посредник.

Мы создаём класс PhysicsBob, который наследуется от Bob и добавляет все необходимые свойства, а также хранит ссылку на физическое тело. Конструктор принимает тело (body) и сохраняет его в this.body. Остальные свойства инициализируются значениями по умолчанию или берутся из фрейма текстуры.

class PhysicsBob extends Phaser.GameObjects.Bob
{
    constructor (blitter, x, y, frame, visible, body)
    {
        super(blitter, x, y, frame, visible);
        this.body = body;
        this.scaleX = 1;
        this.scaleY = 1;
        this.angle = 0;
        // ... другие необходимые свойства
        this.width = this.frame.realWidth;
        this.height = this.frame.realHeight;
    }
}

Именно объекты этого класса будут представлять наших врагов на сцене, объединяя лёгкую графику и тяжёлую физику.

Выпуск и переиспользование врагов

Логика работы с пулом состоит из двух частей: выпуск нового врага и его «утилизация».

**Выпуск (releaseEnemy):** 1. Берём тело из пула (pool.pop()). 2. Создаём PhysicsBob в случайной позиции над экраном со случайной текстуры. 3. **Критическая связка:** присваиваем телу ссылку на созданный Bob (body.gameObject = enemy). Это нужно, чтобы физический движок знал, какой объект перемещать. 4. Вызываем body.setSize(), чтобы тело взяло размеры из Bob (из width/height, которые мы задали в конструкторе). 5. Добавляем тело в мир (this.physics.world.add(body)) и задаём скорость.

const enemy = new PhysicsBob(this.blitter, x, y, frame, true, body);
body.gameObject = enemy;
body.setSize();
this.physics.world.add(body);
body.setVelocity(0, Phaser.Math.Between(200, 500));

**Утилизация (checkEnemyBounds):** Когда враг улетает за нижнюю границу экрана: 1. Извлекаем его тело из мира через world.disableBody(body). Тело перестаёт обновляться в симуляции. 2. Очищаем ссылку body.gameObject. 3. Возвращаем тело в пул (this.pool.push(body)). 4. Уничтожаем только графический объект Bob.

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

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

Приём с пулом переиспользуемых физических тел — это мощный паттерн оптимизации для игр с интенсивным созданием объектов. Он сводит нагрузку на сборку мусора к минимуму, сохраняя стабильный FPS. **Идеи для экспериментов:** 1. Реализуйте двойной пул: не только для тел, но и для графических объектов Bob, чтобы избежать и их создания/уничтожения. 2. Примените этот подход для системы частиц (ParticleEmitter), где каждая частица должна сталкиваться с игроком. 3. Измените логику: возвращайте тело в пул не при выходе за границы, а при столкновении с пулей игрока — это основа для оптимизированного шутера.