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

В играх, где игрок может вести частую стрельбу, создание и уничтожение множества объектов «на лету» может сильно ударить по производительности. Паттерн «Пул объектов» решает эту проблему, позволяя заранее создать набор переиспользуемых объектов, например, пуль. В этой статье мы разберем, как реализовать пул снарядов в Phaser, используя физический движок Matter.js, и настроить категории столкновений для точного контроля над взаимодействиями объектов в космическом шутере.

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

Живой запуск

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

Исходный код


class Bullet extends Phaser.Physics.Matter.Sprite
{
    lifespan;

    constructor (world, x, y, texture, bodyOptions)
    {
        super(world, x, y, texture, null, { plugin: bodyOptions });

        this.setFrictionAir(0);
        this.setFixedRotation();
        this.setActive(false);

        this.scene.add.existing(this);

        this.world.remove(this.body, true);
    }

    fire (x, y, angle, speed)
    {
        this.world.add(this.body);

        this.setPosition(x, y);
        this.setActive(true);
        this.setVisible(true);

        this.setRotation(angle);
        this.setVelocityX(speed * Math.cos(angle));
        this.setVelocityY(speed * Math.sin(angle));

        this.lifespan = 1000;
    }

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

        this.lifespan -= delta;

        if (this.lifespan <= 0)
        {
            this.setActive(false);
            this.setVisible(false);
            this.world.remove(this.body, true);
        }
    }
}

class Enemy extends Phaser.Physics.Matter.Sprite
{
    constructor (world, x, y, texture, bodyOptions)
    {
        super(world, x, y, texture, null, { plugin: bodyOptions });

        this.play('eyes');

        this.setFrictionAir(0);

        this.scene.add.existing(this);

        const angle = Phaser.Math.Between(0, 360);
        const speed = Phaser.Math.FloatBetween(1, 3);

        this.setAngle(angle);

        this.setAngularVelocity(Phaser.Math.FloatBetween(-0.05, 0.05));

        this.setVelocityX(speed * Math.cos(angle));
        this.setVelocityY(speed * Math.sin(angle));
    }

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

class Asteroid extends Phaser.Physics.Matter.Sprite
{
    constructor (world, x, y, frame, bodyOptions)
    {
        super(world, x, y, 'asteroids', frame, { plugin: bodyOptions });

        this.setFrictionAir(0);

        this.scene.add.existing(this);

        const angle = Phaser.Math.Between(0, 360);
        const speed = Phaser.Math.FloatBetween(1, 3);

        this.setAngle(angle);

        this.setAngularVelocity(Phaser.Math.FloatBetween(-0.05, 0.05));

        this.setVelocityX(speed * Math.cos(angle));
        this.setVelocityY(speed * Math.sin(angle));
    }

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

class Example extends Phaser.Scene
{
    cursors;
    ship;
    bullets;
    asteroids;
    enemies;

    shipCollisionCategory;
    bulletCollisionCategory;
    enemiesCollisionCategory;
    asteroidsCollisionCategory;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('ship', 'assets/sprites/thrust_ship.png');
        // this.load.image('bullet', 'assets/sprites/bullets/bullet7.png');
        this.load.image('bullet', 'assets/sprites/shmup-bullet.png');
        this.load.spritesheet('enemy', 'assets/sprites/metalface78x92.png', { frameWidth: 78, frameHeight: 92 });
        this.load.unityAtlas('asteroids', 'assets/atlas/asteroids.png', 'assets/atlas/asteroids.png.meta');
    }

    create ()
    {
        const wrapBounds = {
            wrap: {
                min: { x: 0, y: 0 },
                max: { x: 800, y: 600 }
            }
        };

        this.anims.create({
            key: 'eyes',
            frames: this.anims.generateFrameNumbers('enemy', { start: 0, end: 3 }),
            frameRate: 20,
            repeat: -1
        });

        this.enemiesCollisionCategory = this.matter.world.nextCategory();
        this.shipCollisionCategory = this.matter.world.nextCategory();
        this.bulletCollisionCategory = this.matter.world.nextCategory();
        this.asteroidsCollisionCategory = this.matter.world.nextCategory();

        this.ship = this.matter.add.image(400, 300, 'ship', null, { plugin: wrapBounds });

        this.ship.setFrictionAir(0.02);
        this.ship.setFixedRotation();
        this.ship.setCollisionCategory(this.shipCollisionCategory);

        this.ship.setCollidesWith([ this.enemiesCollisionCategory ]);

        this.bullets = [];

        for (let i = 0; i < 64; i++)
        {
            const bullet = new Bullet(this.matter.world, 0, 0, 'bullet', wrapBounds);

            bullet.setCollisionCategory(this.bulletCollisionCategory);
            bullet.setCollidesWith([ this.enemiesCollisionCategory, this.asteroidsCollisionCategory ]);
            bullet.setOnCollide(this.bulletVsEnemy);

            this.bullets.push(bullet);
        }

        this.asteroids = [];

        for (let i = 0; i < 16; i++)
        {
            const x = Phaser.Math.Between(0, 800);
            const y = Phaser.Math.Between(0, 600);
            const frame = Phaser.Math.Between(0, 31);

            const asteroid = new Asteroid(this.matter.world, x, y, `asteroids_${frame}`, wrapBounds);

            asteroid.setCollisionCategory(this.asteroidsCollisionCategory);
            asteroid.setCollidesWith([ this.shipCollisionCategory, this.bulletCollisionCategory ]);

            this.asteroids.push(asteroid);
        }

        /*
        this.enemies = [];

        for (let i = 0; i < 6; i++)
        {
            const enemy = new Enemy(this.matter.world, Phaser.Math.Between(0, 800), Phaser.Math.Between(0, 600), 'enemy', wrapBounds);

            enemy.setCollisionCategory(this.enemiesCollisionCategory);
            enemy.setCollidesWith([ this.shipCollisionCategory, this.bulletCollisionCategory ]);

            this.enemies.push(enemy);
        }
        */

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

        this.input.keyboard.on('keydown-SPACE', () => {

            const bullet = this.bullets.find(bullet => !bullet.active);

            if (bullet)
            {
                bullet.fire(this.ship.x, this.ship.y, this.ship.rotation, 5);
            }

        });
    }

    bulletVsEnemy (collisionData)
    {
        const bullet = collisionData.bodyA.gameObject;
        const enemy = collisionData.bodyB.gameObject;

        bullet.setActive(false);
        bullet.setVisible(false);
        bullet.world.remove(bullet.body, true);

        enemy.setActive(false);
        enemy.setVisible(false);
        enemy.world.remove(enemy.body, true);
    }

    update ()
    {
        if (this.cursors.left.isDown)
        {
            this.ship.setAngularVelocity(-0.15);
        }
        else if (this.cursors.right.isDown)
        {
            this.ship.setAngularVelocity(0.15);
        }
        else
        {
            this.ship.setAngularVelocity(0);
        }

        if (this.cursors.up.isDown)
        {
            this.ship.thrust(0.001);
        }
    }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#000000',
    parent: 'phaser-example',
    physics: {
        default: 'matter',
        matter: {
            debug: false,
            plugins: {
                wrap: true
            },
            gravity: {
                x: 0,
                y: 0
            }
        }
    },
    scene: Example
};

const game = new Phaser.Game(config);

Основы пула объектов: класс Bullet

Ключевая идея пула — не создавать и не удалять объекты постоянно, а переиспользовать их. Наш класс Bullet наследуется от Phaser.Physics.Matter.Sprite и управляет жизненным циклом одного снаряда.

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

constructor (world, x, y, texture, bodyOptions)
{
    super(world, x, y, texture, null, { plugin: bodyOptions });
    this.setFrictionAir(0);
    this.setFixedRotation();
    this.setActive(false);
    this.scene.add.existing(this);
    this.world.remove(this.body, true);
}

Метод fire «оживляет» пулю: добавляет тело обратно в мир, задает позицию, угол, скорость и устанавливает время жизни (lifespan).

fire (x, y, angle, speed)
{
    this.world.add(this.body);
    this.setPosition(x, y);
    this.setActive(true);
    this.setVisible(true);
    this.setRotation(angle);
    this.setVelocityX(speed * Math.cos(angle));
    this.setVelocityY(speed * Math.sin(angle));
    this.lifespan = 1000;
}

В методе preUpdate отсчитывается время жизни. Когда lifespan истекает, пуля снова деактивируется, становится невидимой, и ее тело удаляется из мира, возвращаясь в состояние для повторного использования.

preUpdate (time, delta)
{
    super.preUpdate(time, delta);
    this.lifespan -= delta;
    if (this.lifespan <= 0)
    {
        this.setActive(false);
        this.setVisible(false);
        this.world.remove(this.body, true);
    }
}

Инициализация пула и управление стрельбой

В сцене Example мы создаем массив bullets и заполняем его 64 заранее созданными, но неактивными пулями. Каждой пуле настраиваются категории столкновений.

this.bullets = [];
for (let i = 0; i < 64; i++)
{
    const bullet = new Bullet(this.matter.world, 0, 0, 'bullet', wrapBounds);
    bullet.setCollisionCategory(this.bulletCollisionCategory);
    bullet.setCollidesWith([ this.enemiesCollisionCategory, this.asteroidsCollisionCategory ]);
    bullet.setOnCollide(this.bulletVsEnemy);
    this.bullets.push(bullet);
}

Стрельба происходит по нажатию пробела. Мы ищем первую неактивную пулю в массиве с помощью метода find. Если такая пуля найдена, вызываем ее метод fire, передавая текущие координаты и угол корабля.

this.input.keyboard.on('keydown-SPACE', () => {
    const bullet = this.bullets.find(bullet => !bullet.active);
    if (bullet)
    {
        bullet.fire(this.ship.x, this.ship.y, this.ship.rotation, 5);
    }
});

Этот подход гарантирует, что мы никогда не создаем новые экземпляры Bullet во время игры, а только переиспользуем существующие из пула.

Категории столкновений в Matter.js

Matter.js позволяет тонко настраивать, какие объекты должны сталкиваться друг с другом, используя битовые маски категорий. Это критически важно в шутере, чтобы пули не сталкивались друг с другом или своим кораблем.

Сначала генерируем уникальные категории для каждого типа объектов:

this.enemiesCollisionCategory = this.matter.world.nextCategory();
this.shipCollisionCategory = this.matter.world.nextCategory();
this.bulletCollisionCategory = this.matter.world.nextCategory();
this.asteroidsCollisionCategory = this.matter.world.nextCategory();

Затем настраиваем, с кем может сталкиваться каждый объект. Например, кораблю важно сталкиваться только с врагами:

this.ship.setCollisionCategory(this.shipCollisionCategory);
this.ship.setCollidesWith([ this.enemiesCollisionCategory ]);

А пуля должна взаимодействовать только с врагами и астероидами, игнорируя другие пули и корабль игрока:

bullet.setCollisionCategory(this.bulletCollisionCategory);
bullet.setCollidesWith([ this.enemiesCollisionCategory, this.asteroidsCollisionCategory ]);

Обработка столкновений и реакция на попадание

Когда столкновение происходит, вызывается коллбэк, назначенный через setOnCollide. В нашем случае это метод bulletVsEnemy.

bulletVsEnemy (collisionData)
{
    const bullet = collisionData.bodyA.gameObject;
    const enemy = collisionData.bodyB.gameObject;
    bullet.setActive(false);
    bullet.setVisible(false);
    bullet.world.remove(bullet.body, true);
    enemy.setActive(false);
    enemy.setVisible(false);
    enemy.world.remove(enemy.body, true);
}

Коллбэк получает данные столкновения collisionData. Мы извлекаем игровые объекты из тел и для каждого из них выполняем одинаковые действия: деактивация, скрытие и удаление физического тела из мира. Это возвращает и пулю, и врага в их пулы для последующего переиспользования. Важно, что сама логика «уничтожения» врага инкапсулирована здесь, а не в классе Enemy.

Движение корабля и врагов

Управление кораблем обрабатывается в методе update сцены в ответ на нажатия клавиш-стрелок.

if (this.cursors.left.isDown)
{
    this.ship.setAngularVelocity(-0.15);
}
else if (this.cursors.right.isDown)
{
    this.ship.setAngularVelocity(0.15);
}
else
{
    this.ship.setAngularVelocity(0);
}
if (this.cursors.up.isDown)
{
    this.ship.thrust(0.001);
}

Классы Enemy и Asteroid демонстрируют альтернативный подход: их движение инициализируется один раз в конструкторе случайными значениями. Они получают начальную скорость и небольшую случайную угловую скорость (setAngularVelocity), что заставляет их медленно вращаться в полете. Это создает эффект более «живого» игрового поля.

const angle = Phaser.Math.Between(0, 360);
const speed = Phaser.Math.FloatBetween(1, 3);
this.setAngle(angle);
this.setAngularVelocity(Phaser.Math.FloatBetween(-0.05, 0.05));
this.setVelocityX(speed * Math.cos(angle));
this.setVelocityY(speed * Math.sin(angle));

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

Использование пула объектов для снарядов — это фундаментальная оптимизация для экшен-игр. В сочетании с категориями столкновений Matter.js это дает полный контроль над физикой взаимодействий. Для экспериментов попробуйте: изменить размер пула и отследить производительность, добавить разные типы оружия с уникальными пулами, реализовать систему здоровья для врагов вместо мгновенного уничтожения или создать эффекты частиц при столкновениях, используя this.add.particles.