О чем этот пример
Создание динамичного шутера с множеством объектов — классическая задача, которая может быстро привести к проблемам с производительностью. В этой статье мы разберем пример из официальной коллекции Phaser, который демонстрирует профессиональный подход к управлению снарядами через пулы объектов (Object Pooling) и эффективное использование Arcade Physics. Вы научитесь создавать переиспользуемые группы спрайтов, настраивать столкновения и создавать визуальные эффекты, не нагружая производительность игры.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Bullet extends Phaser.Physics.Arcade.Image
{
fire (x, y, vx, vy)
{
this.enableBody(true, x, y, true, true);
this.setVelocity(vx, vy);
}
onCreate ()
{
this.disableBody(true, true);
this.body.collideWorldBounds = true;
this.body.onWorldBounds = true;
}
onWorldBounds ()
{
this.disableBody(true, true);
}
}
class Bullets extends Phaser.Physics.Arcade.Group
{
constructor (world, scene, config)
{
super(
world,
scene,
{ ...config, classType: Bullet, createCallback: Bullets.prototype.onCreate }
);
}
fire (x, y, vx, vy)
{
const bullet = this.getFirstDead(false);
if (bullet)
{
bullet.fire(x, y, vx, vy);
}
}
onCreate (bullet)
{
bullet.onCreate();
}
poolInfo ()
{
return `${this.name} total=${this.getLength()} active=${this.countActive(true)} inactive=${this.countActive(false)}`;
}
}
class Example extends Phaser.Scene
{
bullets;
enemy;
enemyBullets;
enemyFiring;
enemyMoving;
plasma;
player;
stars;
text;
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('bullet', 'assets/sprites/bullets/bullet7.png');
this.load.image('enemyBullet', 'assets/sprites/bullets/bullet6.png');
this.load.image('ship', 'assets/sprites/bsquadron1.png');
this.load.image('starfield', 'assets/skies/starfield.png');
this.load.spritesheet('enemy', 'assets/sprites/bsquadron-enemies.png', {
frameWidth: 192,
frameHeight: 160
});
}
create ()
{
this.stars = this.add.blitter(0, 0, 'starfield');
this.stars.create(0, 0);
this.stars.create(0, -512);
this.bullets = this.add.existing(
new Bullets(this.physics.world, this, { name: 'bullets' })
);
this.bullets.createMultiple({
key: 'bullet',
quantity: 5
});
this.enemyBullets = this.add.existing(
new Bullets(this.physics.world, this, { name: 'enemyBullets' })
);
this.enemyBullets.createMultiple({
key: 'enemyBullet',
quantity: 5
});
this.enemy = this.physics.add.sprite(256, 128, 'enemy', 1);
this.enemy.setBodySize(160, 64);
// Hit points
this.enemy.state = 5;
this.enemyMoving = this.tweens.add({
targets: this.enemy.body.velocity,
props: {
x: { from: 150, to: -150, duration: 4000 },
y: { from: 50, to: -50, duration: 2000 }
},
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
this.enemyFiring = this.time.addEvent({
delay: 750,
loop: true,
callback: () =>
{
this.enemyBullets.fire(this.enemy.x, this.enemy.y + 32, 0, 150);
}
});
this.player = this.physics.add.image(256, 448, 'ship');
this.plasma = this.add.particles(0, 0, 'bullet', {
alpha: { start: 1, end: 0, ease: 'Cubic.easeIn' },
blendMode: Phaser.BlendModes.SCREEN,
frequency: -1,
lifespan: 500,
radial: false,
scale: { start: 1, end: 5, ease: 'Cubic.easeOut' }
});
this.text = this.add.text(0, 480, '', {
font: '16px monospace',
fill: 'aqua'
});
this.physics.add.overlap(this.enemy, this.bullets, (enemy, bullet) =>
{
const { x, y } = bullet.body.center;
enemy.state -= 1;
bullet.disableBody(true, true);
// this.plasma.setSpeedY(0.2 * bullet.body.velocity.y).emitParticleAt(x, y);
this.plasma.emitParticleAt(x, y);
if (enemy.state <= 0)
{
enemy.setFrame(3);
enemy.body.checkCollision.none = true;
this.enemyFiring.remove();
this.enemyMoving.stop();
}
});
this.physics.add.overlap(this.player, this.enemyBullets, (player, bullet) =>
{
const { x, y } = bullet.body.center;
bullet.disableBody(true, true);
// this.plasma.setSpeedY(0.2 * bullet.body.velocity.y).emitParticleAt(x, y);
this.plasma.emitParticleAt(x, y);
});
this.physics.world.on('worldbounds', (body) =>
{
body.gameObject.onWorldBounds();
});
this.input.on('pointermove', (pointer) =>
{
this.player.x = pointer.worldX;
});
this.input.on('pointerdown', () =>
{
this.bullets.fire(this.player.x, this.player.y, 0, -300);
});
}
update ()
{
this.stars.y += 1;
this.stars.y %= 512;
this.text.setText([ this.bullets.poolInfo(), this.enemyBullets.poolInfo() ]);
}
}
const config = {
type: Phaser.AUTO,
width: 512,
height: 512,
pixelArt: true,
parent: 'phaser-example',
physics: {
default: 'arcade',
arcade: { debug: false }
},
scene: Example
};
const game = new Phaser.Game(config);
Архитектура пула снарядов: Классы Bullet и Bullets
Вместо постоянного создания и удаления снарядов, что дорого для производительности, используется паттерн Object Pooling. Создается фиксированный набор объектов (пул), которые активируются и деактивируются по мере необходимости.
Класс Bullet наследуется от Phaser.Physics.Arcade.Image и отвечает за состояние одного снаряда. Метод fire активирует тело снаряда в заданной точке и задает ему скорость.
fire (x, y, vx, vy)
{
this.enableBody(true, x, y, true, true);
this.setVelocity(vx, vy);
}
Метод onCreate вызывается при первом создании снаряда в пуле. Он сразу деактивирует тело и настраивает его для обработки столкновений с границами мира.
onCreate ()
{
this.disableBody(true, true);
this.body.collideWorldBounds = true;
this.body.onWorldBounds = true;
}
Класс Bullets — это группа (Phaser.Physics.Arcade.Group), которая управляет всем пулом. В конструкторе через classType указывается, объекты какого класса будут создаваться, а createCallback позволяет выполнить дополнительную настройку для каждого нового объекта.
constructor (world, scene, config)
{
super(
world,
scene,
{ ...config, classType: Bullet, createCallback: Bullets.prototype.onCreate }
);
}
Метод fire группы ищет первый неактивный снаряд в пуле с помощью getFirstDead и, если находит, вызывает его метод fire.
fire (x, y, vx, vy)
{
const bullet = this.getFirstDead(false);
if (bullet)
{
bullet.fire(x, y, vx, vy);
}
}
Настройка сцены: создание игрока, врага и визуальных эффектов
В методе create сцены Example происходит основная инициализация игровых объектов.
Создаются два независимых пула снарядов: для игрока (this.bullets) и для врага (this.enemyBullets). Оба используют один и тот же класс Bullets, но с разными именами и текстурами.
this.bullets = this.add.existing(
new Bullets(this.physics.world, this, { name: 'bullets' })
);
this.bullets.createMultiple({
key: 'bullet',
quantity: 5
});
Враг (this.enemy) — это физический спрайт. Его хитпоинты хранятся в пользовательском свойстве state. Для движения врага используется твин this.enemyMoving, который циклически меняет его скорость по осям X и Y, создавая патрульный полет.
this.enemyMoving = this.tweens.add({
targets: this.enemy.body.velocity,
props: {
x: { from: 150, to: -150, duration: 4000 },
y: { from: 50, to: -50, duration: 2000 }
},
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
Автоматическая стрельба врага реализована через событие по времени this.enemyFiring, которое с заданным интервалом вызывает выстрел из пула enemyBullets.
Для визуальных эффектов при попадании создается система частиц this.plasma. Ее ключевая настройка frequency: -1 означает, что частицы не испускаются автоматически, а только по команде emitParticleAt.
Обработка столкновений и взаимодействия
Столкновения — сердце игровой механики. В примере используется метод this.physics.add.overlap для регистрации коллбэков при пересечении объектов.
Коллбэк для попадания снаряда игрока во врага уменьшает свойство enemy.state (хитпоинты), деактивирует снаряд и запускает эффект частиц в точке попадания. Когда хитпоинты заканчиваются, враг переключается на кадр с разрушением, его столкновения отключаются, а твин и таймер стрельбы останавливаются.
this.physics.add.overlap(this.enemy, this.bullets, (enemy, bullet) =>
{
const { x, y } = bullet.body.center;
enemy.state -= 1;
bullet.disableBody(true, true);
this.plasma.emitParticleAt(x, y);
if (enemy.state <= 0)
{
enemy.setFrame(3);
enemy.body.checkCollision.none = true;
this.enemyFiring.remove();
this.enemyMoving.stop();
}
});
Аналогично обрабатывается попадание вражеского снаряда в игрока.
Важный момент — обработка вылета снарядов за границы мира. Для этого у каждого тела снаряда включено свойство onWorldBounds. При срабатывании глобального события 'worldbounds' у соответствующего игрового объекта вызывается метод onWorldBounds, который просто деактивирует снаряд, возвращая его в пул.
this.physics.world.on('worldbounds', (body) =>
{
body.gameObject.onWorldBounds();
});
Управление игроком привязано к курсору мыши: движение — на pointermove, выстрел — на pointerdown.
this.input.on('pointerdown', () =>
{
this.bullets.fire(this.player.x, this.player.y, 0, -300);
});
Игровой цикл и отладка
В методе update реализовано два простых, но важных действия.
Первое — анимированный фон (this.stars). Положение блиттера фона постоянно сдвигается вниз, а оператор `%` (остаток от деления) создает эффект бесконечной прокрутки.
this.stars.y += 1;
this.stars.y %= 512;
Второе — отображение отладочной информации. Метод poolInfo класса Bullets возвращает строку с количеством снарядов в пуле: общим, активным и неактивным. Эти данные выводятся на экран, что позволяет в реальном времени отслеживать, как работает пул объектов и нет ли утечек.
this.text.setText([ this.bullets.poolInfo(), this.enemyBullets.poolInfo() ]);
Этот прием крайне полезен при отладке логики повторного использования объектов и балансировке размера пула.
Что попробовать дальше
Данный пример — отличная основа для собственного шутера. Вы можете экспериментировать: увеличить размер пулов для более интенсивной стрельбы, добавить разные типы оружия с отдельными пулами, реализовать систему здоровья для игрока или создать группу врагов. Обратите внимание, как эффектно работает связка пулов объектов, Arcade Physics и событийной модели Phaser, обеспечивая и высокую производительность, и чистоту кода.
