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

Работа с текстом — неотъемлемая часть разработки игр. Phaser предлагает мощную, но не всегда очевидную систему переноса строк (word wrap), которая позволяет гибко управлять отображением текста в интерфейсах, диалогах и описаниях. Эта статья разбирает исходный код тестового примера, демонстрируя все аспекты API: базовый и продвинутый перенос по ширине, а также использование кастомных функций-колбэков для полного контроля над разбивкой текста. Вы научитесь точно управлять тем, как текст ложится на экран вашей игры.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    testsPassed = 0;
    totalTests = 0;

    create ()
    {
        const text = this.make.text({
            x: 400,
            y: 300,
            text: '  The   sky above the port was the color of television, tuned to a dead channel.',
            origin: 0.5,
            style: {
                font: 'bold 30px Arial',
                fill: 'white'
            }
        });

        // --- DEFAULTS ---
        this.assert('Word wrap width should be null', text.style.wordWrapWidth === null);
        this.assert('Advanced word wrap should be false', text.style.wordWrapUseAdvanced === false);
        this.assert('Word wrap callback should be null', text.style.wordWrapCallback === null);
        this.assert('Word wrap callback scope should be null', text.style.wordWrapCallbackScope === null);
        this.assert('Wrapped text should be one line', text.getWrappedText().length === 1);

        {
            // --- BASIC WIDTH WRAPPING ---
            text.setWordWrapWidth(200, false);
            let lines = text.getWrappedText();
            this.assert('Wrapped text should be multiple lines', lines.length > 1);
            this.assert('First line should not be trimmed', lines[0].startsWith(' '));
            this.assert('First line not have whitespace collapsed', lines[0].includes('The   sky'));
            text.setWordWrapWidth(null);
        }

        {
            // --- DISABLING WRAPPING AFTER ENABLING IT ---
            text.setWordWrapWidth(200, false);
            text.setWordWrapWidth(null);
            this.assert('Wrapped text should be one line', text.getWrappedText().length === 1);
        }

        {
            // --- ADVANCED WIDTH WRAPPING ---
            text.setWordWrapWidth(200, true);
            let lines = text.getWrappedText();
            this.assert('Wrapped text should be multiple lines', lines.length > 1);
            this.assert('First line should be trimmed', !lines[0].startsWith(' '));
            this.assert('First line have whitespace collapsed', lines[0].includes('The sky'));
            text.setWordWrapWidth(null);
        }

        {
            // --- WRAPPING CALLBACK, RETURNING ARRAY ---
            text.setWordWrapCallback(function (string, textObject)
            {
                this.assert('Second argument should be the text gameobject', text === textObject);
                this.assert('Scope should match the given scope object', this.testObject === true);
                return [ '1', '2' ];
            }, { testObject: true, assert: this.assert });
            let lines = text.getWrappedText();
            this.assert('Wrapped text should be exactly two lines', lines.length === 2);
            this.assert('Wrapped text should be ["1", "2"]', lines[0] === '1' && lines[1] === '2');
            text.setWordWrapCallback(null);
        }

        {
            // --- WRAPPING CALLBACK, RETURNING STRING ---
            text.setWordWrapCallback(() => '1\n2');
            let lines = text.getWrappedText();
            this.assert('Wrapped text should be exactly two lines', lines.length === 2);
            this.assert('Wrapped text should be ["1", "2"]', lines[0] === '1' && lines[1] === '2');
            text.setWordWrapCallback(null);
        }

        {
            // --- DISABLING WRAPPING CALLBACK AFTER ENABLING IT ---
            text.setWordWrapCallback(text => text, { testObject: true });
            text.setWordWrapCallback(null);
            let lines = text.getWrappedText();
            this.assert('Wrapped text should be one line', lines.length === 1);
        }

        console.log(`${this.testsPassed} / ${this.totalTests} tests passed`);
    }

    assert (message, condition)
    {
        this.totalTests++;
        if (condition) { this.testsPassed++; }
        console.assert(condition, message);
    }
}

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

const game = new Phaser.Game(config);

Создание текстового объекта

Всё начинается с создания объекта Text через фабрику this.make.text(). В примере задаются базовые свойства: позиция, исходная строка, точка привязки и стиль.

const text = this.make.text({
    x: 400,
    y: 300,
    text: '  The   sky above the port was the color of television, tuned to a dead channel.',
    origin: 0.5,
    style: {
        font: 'bold 30px Arial',
        fill: 'white'
    }
});

По умолчанию перенос строк не активен. Проверить это можно через свойства стиля text.style и метод text.getWrappedText(), который возвращает массив строк. Изначально это будет массив из одного элемента — всей исходной строки.

Базовый перенос по ширине

Самый простой способ включить перенос — задать максимальную ширину строки в пикселях с помощью метода setWordWrapWidth(width, useAdvancedWrap). Если второй аргумент useAdvancedWrap равен false, включается базовый режим.

text.setWordWrapWidth(200, false);
let lines = text.getWrappedText();

В этом режиме текст разбивается строго по достижении заданной ширины, но без какой-либо предварительной обработки строки. Пробелы в начале строки и множественные пробелы внутри не удаляются. Чтобы отключить перенос, нужно передать null в качестве ширины.

Продвинутый перенос по ширине

Если передать в setWordWrapWidth вторым аргументом true, активируется продвинутый режим.

text.setWordWrapWidth(200, true);
let lines = text.getWrappedText();

В этом режиме Phaser проводит дополнительную обработку текста перед переносом: удаляет пробелы в начале строки и «схлопывает» множественные пробелы внутри строки в один. Это полезно для аккуратного отображения текста, полученного из внешних источников.

Кастомный перенос через колбэк

Для максимального контроля можно задать свою функцию для переноса с помощью setWordWrapCallback(callback, scope). Функция-колбэк получает исходную строку и сам текстовый объект. Она может вернуть результат в двух форматах.

Первый вариант — вернуть массив строк, где каждый элемент станет новой строкой:

text.setWordWrapCallback(function (string, textObject) {
    return [ '1', '2' ];
}, scopeObject);

Второй вариант — вернуть единую строку, где строки разделены символом \n:

text.setWordWrapCallback(() => '1\n2');

Объект scope (второй аргумент) задаёт контекст (this), в котором будет вызван колбэк. Это позволяет получить доступ к нужным данным или методам внутри функции. Колбэк отключается передачей null.

Проверка результата

Ключевой метод для работы с переносом — getWrappedText(). Он всегда возвращает актуальный массив строк, учитывая текущие настройки ширины и колбэка. В примере он используется после каждой операции для проверки утверждений (assert).

let lines = text.getWrappedText();
this.assert('Wrapped text should be exactly two lines', lines.length === 2);

Этот метод — главный инструмент для отладки и построения логики, зависящей от разбитого текста.

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

Phaser предоставляет три уровня контроля над переносом текста: автоматический по ширине (базовый и продвинутый) и полностью ручной через колбэк. Для UI подойдёт продвинутый режим (setWordWrapWidth(width, true)), а для диалогов со сложными правилами — кастомный колбэк. Поэкспериментируйте: создайте колбэк, который переносит текст только по точкам или добавляет тире в месте разрыва слова, или динамически меняйте ширину переноса в зависимости от размера игрового окна.