О чем этот пример
Создание динамических визуальных эффектов, таких как электрические разряды или лазерные лучи, — частая задача в разработке игр. В этом примере мы разберем, как использовать систему частиц Phaser 3 для генерации эффекта электрической дуги, которая плавно следует по заданной кривой Безье. Вы научитесь управлять эмиттерами частиц, вычислять их позиции и углы наклона на основе геометрии кривой, а также создадите интерактивный инструмент для перетаскивания контрольных точек кривой в реальном времени, что незаменимо для отладки и настройки эффектов.
Версия 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: 400,
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);
}
});
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: '#080808',
parent: 'phaser-example',
scene: Example
};
const game = new Phaser.Game(config);
Подготовка сцены и определение кривой
В методе preload() загружаются необходимые ресурсы: два изображения для частиц (spark0 и spark1) и спрайт для интерактивных контрольных точек.
Основная геометрия эффекта определяется в create(). Создается кубическая кривая Безье с помощью класса Phaser.Curves.CubicBezier. Для ее задания нужны четыре векторные точки: начальная, конечная и две контрольные, влияющие на форму изгиба.
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);
Создание и расстановка эмиттеров частиц
Для визуализации "электричества" создаются два менеджера частиц (ParticleManager), каждый со своим изображением. Эффект разряда формируется множеством отдельных эмиттеров, расположенных вдоль кривой.
В цикле от 0 до this.max (16) вычисляется позиция на кривой для каждого эмиттера. Метод curve.getUtoTmapping(c / this.max) переводит равномерное распределение параметра `uв параметрtкривой с учетом ее длины, что обеспечивает равномерное визуальное расстояние между эмиттерами. Для каждой найденной точкиpвычисляется касательный вектор (tangent`) к кривой.
const t = curve.getUtoTmapping(c / this.max);
const p = curve.getPoint(t);
const tangent = curve.getTangent(t);
Затем на основе касательного вектора вычисляется точка, смещенная на 32 пикселя вправо (перпендикулярно). Угол между исходной точкой на кривой и этой смещенной точкой становится углом испускания (angle) для частиц эмиттера. Это заставляет частицы вылетать перпендикулярно кривой, создавая объемный эффект.
tempVec.copy(tangent).normalizeRightHand().scale(32).add(p);
const angle = Phaser.Math.RadToDeg(Phaser.Math.Angle.BetweenPoints(p, tempVec));
Эмиттеры создаются с чередованием текстур (spark0 и spark1) для разнообразия. Ключевые параметры: скорость с отрицательным максимальным значением (частицы могут лететь "назад"), гравитация, уменьшение масштаба до нуля и режим наложения 'ADD' для яркого свечения. Все созданные эмиттеры сохраняются в массив this.emitters.
this.emitters.push(particles.createEmitter({
x: p.x,
y: p.y,
angle: angle,
speed: { min: 100, max: -500 },
gravityY: 400,
scale: { start: 0.2, end: 0.0 },
lifespan: 1000,
blendMode: 'ADD'
}));
Интерактивность: перетаскивание точек кривой
Чтобы можно было гибко настраивать форму разряда, в сцене создаются четыре драггебла (перетаскиваемых спрайта), соответствующие точкам кривой Безье. Конечным точкам присваивается кадр спрайта 1, контрольным — кадр 2.
const point0 = this.add.image(startPoint.x, startPoint.y, 'dragcircle', 1).setInteractive();
// ... остальные точки
point2.setData('isControl', true);
this.input.setDraggable([ point0, point1, point2, point3 ]);
Каждый спрайт через setData() связывается с соответствующим объектом Vector2, лежащим в основе кривой. При перетаскивании (drag) обновляется не только позиция спрайта, но и вектор, который он представляет.
gameObject.getData('vector').set(dragX, dragY);
Самое важное — в том же обработчике события drag происходит полный пересчет позиций и углов для всех эмиттеров. Кривая автоматически обновляется, так как мы изменили ее исходные векторы, и мы заново вычисляем точки и касательные, чтобы мгновенно применить изменения к эмиттерам через setPosition() и setAngle(). Это позволяет видеть, как форма электрической дуги меняется в реальном времени.
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);
// ... пересчет угла
this.emitters[c].setAngle(angle);
}
Анимация: непрерывная эмиссия частиц
Эмиттеры, созданные через particles.createEmitter(), по умолчанию не испускают частицы сами по себе. Для запуска непрерывной анимации в методе update() каждый кадр для каждого эмиттера в массиве вызывается метод emitParticle().
update ()
{
this.emitters.forEach(emitter =>
{
emitter.emitParticle();
});
}
Этот подход дает полный контроль над частотой испускания. Если нужно изменить интенсивность эффекта, можно, например, вызывать emitParticle() не каждый кадр, а с определенной вероятностью или интервалом.
Конфигурация игры задает черный фон (#080808), который идеально контрастирует с яркими, полупрозрачными частицами в режиме наложения ADD.
Что попробовать дальше
Вы создали динамичный эффект электрического разряда, который математически привязан к гибкой кривой Безье. Этот подход открывает множество возможностей: замените текстуры частиц на сгустки плазмы для лазерного луча или на капли для стилизованного водопада. Экспериментируйте с параметрами эмиттера: попробуйте maxParticles, измените lifespan или speed для другого визуального ощущения. Используйте несколько кривых и разные наборы эмиттеров для создания сложных композиций, таких как энергетические щиты или магические барьеры. Интерактивная система перетаскивания точек — мощный инструмент для дизайнеров, позволяющий настраивать эффекты прямо в игре.
