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

Визуализация диалогов — ключевой элемент для повествования во многих играх. Готовые UI-ассеты не всегда подходят под уникальный стиль проекта. В этой статье мы разберем, как с нуля создавать кастомизируемые речевые пузыри, используя графический контекст Phaser. Вы научитесь рисовать скругленные прямоугольники с тенями, добавлять стрелки-указатели и, что самое важное, — точно центрировать многострочный текст внутри графической фигуры, что пригодится для создания любых пользовательских интерфейсов.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('akira', 'assets/pics/akira.jpg');
    }

    create ()
    {
        this.add.image(400, 350, 'akira').setScale(0.7);

        this.createSpeechBubble(20, 20, 320, 160, '“Twin ceramic rotor drives on each wheel! And these look like computer controlled anti-lock brakes! Wow, 200 horses at 12,000 rpm!”');

        this.createSpeechBubble(370, 120, 400, 180, '“Kaneda, you\'ve always been a pain in the ass, you know. You\'ve been telling me what to do since we were kids. You always treat me like a kid. You always show up and start bossing me around, and don\'t you deny it!”');

        this.createSpeechBubble(70, 400, 250, 100, '“And now you\'re a boss, too... of this pile of rubble.”');
    }

    createSpeechBubble (x, y, width, height, quote)
    {
        const bubbleWidth = width;
        const bubbleHeight = height;
        const bubblePadding = 10;
        const arrowHeight = bubbleHeight / 4;

        const bubble = this.add.graphics({ x: x, y: y });

        //  Bubble shadow
        bubble.fillStyle(0x222222, 0.5);
        bubble.fillRoundedRect(6, 6, bubbleWidth, bubbleHeight, 16);

        //  Bubble color
        bubble.fillStyle(0xffffff, 1);

        //  Bubble outline line style
        bubble.lineStyle(4, 0x565656, 1);

        //  Bubble shape and outline
        bubble.strokeRoundedRect(0, 0, bubbleWidth, bubbleHeight, 16);
        bubble.fillRoundedRect(0, 0, bubbleWidth, bubbleHeight, 16);

        //  Calculate arrow coordinates
        const point1X = Math.floor(bubbleWidth / 7);
        const point1Y = bubbleHeight;
        const point2X = Math.floor((bubbleWidth / 7) * 2);
        const point2Y = bubbleHeight;
        const point3X = Math.floor(bubbleWidth / 7);
        const point3Y = Math.floor(bubbleHeight + arrowHeight);

        //  Bubble arrow shadow
        bubble.lineStyle(4, 0x222222, 0.5);
        bubble.lineBetween(point2X - 1, point2Y + 6, point3X + 2, point3Y);

        //  Bubble arrow fill
        bubble.fillTriangle(point1X, point1Y, point2X, point2Y, point3X, point3Y);
        bubble.lineStyle(2, 0x565656, 1);
        bubble.lineBetween(point2X, point2Y, point3X, point3Y);
        bubble.lineBetween(point1X, point1Y, point3X, point3Y);

        const content = this.add.text(0, 0, quote, { fontFamily: 'Arial', fontSize: 20, color: '#000000', align: 'center', wordWrap: { width: bubbleWidth - (bubblePadding * 2) } });

        const b = content.getBounds();

        content.setPosition(bubble.x + (bubbleWidth / 2) - (b.width / 2), bubble.y + (bubbleHeight / 2) - (b.height / 2));
    }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    parent: 'phaser-example',
    scene: Example
};

const game = new Phaser.Game(config);

Подготовка сцены и основа метода

В примере используется одна сцена. В методе preload загружается фоновая картинка. Вся магия происходит в create, где фон размещается на сцене, а затем трижды вызывается пользовательский метод createSpeechBubble с разными координатами, размерами и текстом.

Метод createSpeechBubble — это сердце примера. Он принимает стартовые координаты (x, y), ширину и высоту будущего пузыря, а также строку с текстом. Внутри метода создается объект Graphics, который является основным инструментом для рисования примитивов в Phaser.

const bubble = this.add.graphics({ x: x, y: y });

Рисуем тело пузыря с тенью и обводкой

Phaser Graphics работает как холст: мы задаем стили, а затем вызываем команды для отрисовки фигур. Стили применяются ко всем последующим операциям, пока не будут изменены.

Сначала рисуется тень. Устанавливается цвет и полупрозрачность с помощью fillStyle, затем рисуется скругленный прямоугольник fillRoundedRect со смещением. Обратите внимание, что все координаты внутри Graphics считаются относительно его позиции (`x,y`), заданной при создании.

//  Bubble shadow
bubble.fillStyle(0x222222, 0.5);
 bubble.fillRoundedRect(6, 6, bubbleWidth, bubbleHeight, 16);

После этого стили меняются для отрисовки основного тела пузыря: белая заливка и серая обводка толщиной 4 пикселя. Сначала задается lineStyle для обводки, а затем одной командой strokeRoundedRect рисуется контур, а fillRoundedRect — заливка. Порядок важен: если рисовать заливку после обводки, она может перекрыть часть линии.

//  Bubble color and outline
bubble.fillStyle(0xffffff, 1);
bubble.lineStyle(4, 0x565656, 1);
bubble.strokeRoundedRect(0, 0, bubbleWidth, bubbleHeight, 16);
bubble.fillRoundedRect(0, 0, bubbleWidth, bubbleHeight, 16);

Создание и стилизация стрелки

Стрелка, указывающая на говорящего, рисуется как залитый треугольник с обводкой. Её тень рисуется отдельной линией.

Ключевой момент — расчет координат трех вершин треугольника (point1, point2, point3). Они привязаны к пропорциям пузыря: основание стрелки располагается по центру нижней границы, а её острие опущено на величину arrowHeight (четверть высоты пузыря). Это делает стрелку адаптивной к размеру баббла.

const point1X = Math.floor(bubbleWidth / 7);
const point1Y = bubbleHeight;
const point2X = Math.floor((bubbleWidth / 7) * 2);
const point2Y = bubbleHeight;
const point3X = Math.floor(bubbleWidth / 7);
const point3Y = Math.floor(bubbleHeight + arrowHeight);

Сначала рисуется тень стрелки с помощью lineBetween. Затем сама стрелка: fillTriangle создает залитый треугольник, а lineBetween дорисовывает его границы.

bubble.fillTriangle(point1X, point1Y, point2X, point2Y, point3X, point3Y);
bubble.lineStyle(2, 0x565656, 1);
bubble.lineBetween(point2X, point2Y, point3X, point3Y);
bubble.lineBetween(point1X, point1Y, point3X, point3Y);

Добавление и точное центрирование текста

Текст создается как отдельный игровой объект с помощью this.add.text. В стилях критически важно свойство wordWrap. Оно ограничивает ширину текста, чтобы он переносился и не выходил за границы пузыря. Ширина рассчитывается как bubbleWidth - (bubblePadding * 2).

const content = this.add.text(0, 0, quote, {
    fontFamily: 'Arial',
    fontSize: 20,
    color: '#000000',
    align: 'center',
    wordWrap: { width: bubbleWidth - (bubblePadding * 2) }
});

Самый изящный трюк — центрирование. Объект Text изначально помещается в (0,0). Метод getBounds() возвращает его геометрические границы (ширину и высоту с учетом переносов). Эти данные используются для вычисления позиции, которая поместит текст ровно по центру графического пузыря. Позиция рассчитывается относительно позиции (bubble.x, bubble.y) самого объекта Graphics.

const b = content.getBounds();
content.setPosition(
    bubble.x + (bubbleWidth / 2) - (b.width / 2),
    bubble.y + (bubbleHeight / 2) - (b.height / 2)
);

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

Вы освоили мощный подход к созданию динамических UI-элементов в Phaser, комбинируя рисование графики и работу с текстом. Этот метод дает полный контроль над стилем. Для экспериментов попробуйте: анимировать появление пузыря через tweens, менять форму стрелки или тело пузыря на эллипс, добавить разные стили текста (жирный, курсив) внутри одной цитаты с помощью setTextStyle, или сделать систему, где стрелка автоматически поворачивается в сторону заданной точки (например, спрайта персонажа).