О чем этот пример
Создание сложных визуальных эффектов, таких как электрические разряды или энергетические потоки, часто требует, чтобы частицы двигались по заданным траекториям. В этой статье мы разберем практический пример из официального репозитория Phaser, который демонстрирует, как привязать несколько эмиттеров частиц к кривой Безье и сделать эту кривую интерактивной. Вы научитесь динамически рассчитывать позицию и угол наклона для каждого эмиттера, создавая живые, изменяемые эффекты, которые можно использовать для магических заклинаний, силовых полей или неоновых трубок.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.55.2.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
emitters = [];
max = 16;
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('spark0', 'assets/particles/blue.png');
this.load.image('spark1', 'assets/particles/yellow.png');
this.load.spritesheet('dragcircle', 'assets/sprites/dragcircle.png', { frameWidth: 16 });
}
create ()
{
const tempVec = new Phaser.Math.Vector2();
const startPoint = new Phaser.Math.Vector2(50, 260);
const controlPoint1 = new Phaser.Math.Vector2(610, 25);
const controlPoint2 = new Phaser.Math.Vector2(320, 370);
const endPoint = new Phaser.Math.Vector2(735, 550);
const curve = new Phaser.Curves.CubicBezier(startPoint, controlPoint1, controlPoint2, endPoint);
const spark0 = this.add.particles('spark0');
const spark1 = this.add.particles('spark1');
for (let c = 0; c <= this.max; c++)
{
const t = curve.getUtoTmapping(c / this.max);
const p = curve.getPoint(t);
const tangent = curve.getTangent(t);
// tempVec.copy(tangent).normalizeRightHand().scale(-32).add(p);
tempVec.copy(tangent).scale(-32).add(p);
const angle = Phaser.Math.RadToDeg(Phaser.Math.Angle.BetweenPoints(p, tempVec));
const particles = (c % 2 === 0) ? spark0 : spark1;
this.emitters.push(particles.createEmitter({
x: p.x,
y: p.y,
angle: angle,
speed: { min: 100, max: -500 },
gravityY: 200,
scale: { start: 0.2, end: 0.0 },
lifespan: 1000,
blendMode: 'ADD'
}));
}
const point0 = this.add.image(startPoint.x, startPoint.y, 'dragcircle', 1).setInteractive();
const point1 = this.add.image(endPoint.x, endPoint.y, 'dragcircle', 1).setInteractive();
const point2 = this.add.image(controlPoint1.x, controlPoint1.y, 'dragcircle', 2).setInteractive();
const point3 = this.add.image(controlPoint2.x, controlPoint2.y, 'dragcircle', 2).setInteractive();
point0.setData('vector', startPoint);
point1.setData('vector', endPoint);
point2.setData('vector', controlPoint1);
point3.setData('vector', controlPoint2);
point0.setData('isControl', false);
point1.setData('isControl', false);
point2.setData('isControl', true);
point3.setData('isControl', true);
this.input.setDraggable([ point0, point1, point2, point3 ]);
this.input.on('dragstart', (pointer, gameObject) =>
{
gameObject.setFrame(1);
});
this.input.on('drag', (pointer, gameObject, dragX, dragY) =>
{
gameObject.x = dragX;
gameObject.y = dragY;
gameObject.getData('vector').set(dragX, dragY);
for (let c = 0; c <= this.max; c++)
{
const t = curve.getUtoTmapping(c / this.max);
const p = curve.getPoint(t);
const tangent = curve.getTangent(t);
this.emitters[c].setPosition(p.x, p.y);
tempVec.copy(tangent).scale(-32).add(p);
// tempVec.copy(tangent).normalizeRightHand().scale(-32).add(p);
const angle = Phaser.Math.RadToDeg(Phaser.Math.Angle.BetweenPoints(p, tempVec));
this.emitters[c].setAngle(angle, angle);
}
});
this.input.on('dragend', (pointer, gameObject) =>
{
if (gameObject.getData('isControl'))
{
gameObject.setFrame(2);
}
else
{
gameObject.setFrame(1);
}
});
}
update ()
{
this.emitters.forEach(emitter =>
{
emitter.emitParticle();
});
}
}
const config = {
type: Phaser.AUTO,
width: 800,
height: 600,
backgroundColor: '#000000',
parent: 'phaser-example',
scene: Example
};
const game = new Phaser.Game(config);
Подготовка сцены и построение кривой
В методе preload загружаются необходимые ассеты: два изображения для частиц (spark0 и spark1) и спрайтшит для управляющих точек. В create инициализируется вектор для временных вычислений и определяется кубическая кривая Безье. Кривая задается четырьмя точками: начальной, конечной и двумя контрольными, которые влияют на её форму.
const tempVec = new Phaser.Math.Vector2();
const startPoint = new Phaser.Math.Vector2(50, 260);
const controlPoint1 = new Phaser.Math.Vector2(610, 25);
const controlPoint2 = new Phaser.Math.Vector2(320, 370);
const endPoint = new Phaser.Math.Vector2(735, 550);
const curve = new Phaser.Curves.CubicBezier(startPoint, controlPoint1, controlPoint2, endPoint);
Создание и расстановка эмиттеров частиц
Создаются два менеджера частиц (ParticleEmitterManager) для разных текстур. Затем кривая делится на max+1 сегментов. Для каждой точки на кривой (параметр `t) вычисляется её позиция (p) и вектор касательной (tangent`). Касательная определяет направление кривой в этой точке.
const t = curve.getUtoTmapping(c / this.max);
const p = curve.getPoint(t);
const tangent = curve.getTangent(t);
Чтобы задать угол разлета частиц перпендикулярно кривой, касательная масштабируется, смещается от точки `pи используется для расчета угла черезPhaser.Math.Angle.BetweenPoints`. В зависимости от четности индекса выбирается менеджер частиц, и создается эмиттер с заданными параметрами. Частицы вылетают под рассчитанным углом, имеют случайную скорость (включая отрицательную для разнообразия), гравитацию и эффект растворения.
tempVec.copy(tangent).scale(-32).add(p);
const angle = Phaser.Math.RadToDeg(Phaser.Math.Angle.BetweenPoints(p, tempVec));
const particles = (c % 2 === 0) ? spark0 : spark1;
this.emitters.push(particles.createEmitter({
x: p.x,
y: p.y,
angle: angle,
speed: { min: 100, max: -500 },
gravityY: 200,
scale: { start: 0.2, end: 0.0 },
lifespan: 1000,
blendMode: 'ADD'
}));
Интерактивность: перетаскивание контрольных точек
В сцену добавляются спрайты, представляющие точки кривой. Конечные точки используют первый кадр спрайтшита, контрольные — второй. Каждому спрайту через setData привязывается исходный объект Vector2 и флаг, указывающий, является ли он контрольной точкой.
point0.setData('vector', startPoint);
point0.setData('isControl', false);
Спрайты делаются перетаскиваемыми. Во время перетаскивания (drag) обновляется позиция спрайта и связанного с ним вектора. Затем в цикле для всех эмиттеров заново вычисляется позиция на обновленной кривой и угол эмиссии, которые применяются через setPosition и setAngle. Это заставляет поток частиц мгновенно подстраиваться под новую форму кривой.
gameObject.getData('vector').set(dragX, dragY);
// ... пересчет позиции и угла для всех эмиттеров
this.emitters[c].setPosition(p.x, p.y);
this.emitters[c].setAngle(angle, angle);
Анимация: непрерывная эмиссия частиц
В методе update для каждого эмиттера в массиве this.emitters вызывается метод emitParticle(). Это обеспечивает постоянное рождение новых частиц в каждой точке кривой каждый кадр, создавая непрерывный поток. Без этого вызова эмиттеры испустили бы частицы только один раз при создании.
update ()
{
this.emitters.forEach(emitter =>
{
emitter.emitParticle();
});
}
Что попробовать дальше
Этот пример показывает мощь комбинации кривых, particle system и интерактивности в Phaser 3. Вы можете экспериментировать: изменить blendMode на SCREEN для другого свечения, использовать текстуры с градиентом, добавить случайность в угол (angle: { min: angle-10, max: angle+10 }) для более "дикого" эффекта или заставить частицы следовать по кривой с помощью Particle.Follow. Попробуйте привязать такую систему к траектории движения снаряда или контуру силуэта персонажа.
