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

Движущийся текст по сложной траектории — эффектный приём для титров, интерфейсов или внутриигровых сообщений. В этом примере показано, как заставить каждую букву динамического BitmapText следовать по заранее заданному пути, создавая плавную волнообразную анимацию. Мы разберём ключевые концепции Phaser: создание кривых Path, использование `setDisplayCallback` для управления отображением символов и синхронизацию анимации в игровом цикле.

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

Живой запуск

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

Исходный код


let t = 0;
let path;

class Example extends Phaser.Scene
{
    constructor ()
    {
        super();
    }

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.bitmapFont('desyrel', 'assets/fonts/bitmap/desyrel.png', 'assets/fonts/bitmap/desyrel.xml');
    }

    create ()
    {
        path = new Phaser.Curves.Path(1500, 500);

        path.lineTo(700, 500);
        path.splineTo([ 745, 256, 550, 145, 300, 250, 260, 450, 50, 500 ]);
        path.lineTo(-100, 500);

        const text = this.add.dynamicBitmapText(0, 0, 'desyrel', 'Phaser 3', 64);

        text.setDisplayCallback(this.positionOnPath);

        const graphics = this.add.graphics();

        graphics.lineStyle(1, 0xffffff, 1);

        path.draw(graphics, 128);
    }

    update()
    {
        t += 0.001;

        if (t >= (1 - 0.24))
        {
            t = 0;
        }
    }

    //  data = { color: color, index: index, charCode: charCode, x: x, y: y, scaleX: scaleX, scaleY: scaleY }
    positionOnPath (data)
    {
        var pathVector = path.getPoint(t + ((6 - data.index) * 0.04));

        if (pathVector)
        {
            data.x = pathVector.x;
            data.y = pathVector.y;
        }

        return data;
    }
}

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


const game = new Phaser.Game(config);

Подготовка сцены и загрузка ресурсов

Класс Example расширяет Phaser.Scene. В методе preload загружается bitmap-шрифт. Установка базового URL упрощает загрузку ассетов из удалённого репозитория примеров.

preload ()
{
    this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
    this.load.bitmapFont('desyrel', 'assets/fonts/bitmap/desyrel.png', 'assets/fonts/bitmap/desyrel.xml');
}

Создание пути и текста

В методе create инициализируется главная логика. Сначала создаётся объект Phaser.Curves.Path. Он определяет траекторию движения. Путь строится от начальной точки методом lineTo, затем добавляется сложная кривая splineTo и завершается ещё одним отрезком lineTo.

create ()
{
    path = new Phaser.Curves.Path(1500, 500);
    path.lineTo(700, 500);
    path.splineTo([ 745, 256, 550, 145, 300, 250, 260, 450, 50, 500 ]);
    path.lineTo(-100, 500);
}

Затем создаётся динамический bitmap-текст. Ключевой метод setDisplayCallback назначает функцию обратного вызова positionOnPath, которая будет вызываться для каждого символа при его отрисовке, позволяя модифицировать его свойства.

const text = this.add.dynamicBitmapText(0, 0, 'desyrel', 'Phaser 3', 64);
text.setDisplayCallback(this.positionOnPath);

Для визуализации пути (что полезно при отладке) создаётся объект Graphics и рисуется траектория.

const graphics = this.add.graphics();
graphics.lineStyle(1, 0xffffff, 1);
path.draw(graphics, 128);

Функция обратного вызова для позиционирования

Функция positionOnPath — сердце анимации. Она вызывается для каждого символа текста. Параметр data содержит свойства символа: индекс, координаты и другие. Наша задача — изменить data.x и data.y.

Мы вычисляем позицию на пути с помощью path.getPoint(t). Параметр `t— это нормализованное расстояние вдоль кривой (от 0 до 1). Чтобы буквы не накладывались друг на друга, кtдобавляется смещение, зависящее от индекса символа ((6 - data.index) * 0.04`). Это создаёт эффект «волны», где каждая следующая буква немного отстаёт.

positionOnPath (data)
{
    var pathVector = path.getPoint(t + ((6 - data.index) * 0.04));
    if (pathVector)
    {
        data.x = pathVector.x;
        data.y = pathVector.y;
    }
    return data;
}

Игровой цикл и управление временем

В методе update увеличивается глобальная переменная `t, которая управляет движением вдоль пути. Увеличение на небольшую фиксированную величину (0.001) обеспечивает плавную анимацию. Условиеif (t >= (1 - 0.24))сбрасываетtв ноль, когда анимация почти завершилась, создавая бесконечный цикл. Значение0.24` подобрано эмпирически, чтобы последняя буква успела пройти весь путь до сброса.

update()
{
    t += 0.001;
    if (t >= (1 - 0.24))
    {
        t = 0;
    }
}

Конфигурация и запуск игры

Стандартная конфигурация игры Phaser. Указывается тип рендерера (Phaser.AUTO), элемент-контейнер, размеры холста и главный класс сцены.

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

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

Комбинируя DynamicBitmapText, Curves.Path и callback-функцию, можно создавать сложные текстовые анимации без использования спрайтовых листов. Для экспериментов попробуйте: изменить форму пути с помощью других методов, например circleTo или ellipseTo; варьировать смещение между буквами для другого визуального эффекта; модифицировать в callback не только координаты, но и scaleX, scaleY или color для добавления масштабирования или изменения цвета вдоль пути.