О чем этот пример
В играх, где игрок может вести частую стрельбу, создание и уничтожение множества объектов «на лету» может сильно ударить по производительности. Паттерн «Пул объектов» решает эту проблему, позволяя заранее создать набор переиспользуемых объектов, например, пуль. В этой статье мы разберем, как реализовать пул снарядов в 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.
