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

В классических аркадных играх после установки рекорда часто требуется ввести своё имя для таблицы лидеров. Этот пример демонстрирует, как создать интерактивный интерфейс для ввода короткого имени, используя как клавиатурную навигацию (стрелки и Enter/Space), так и управление мышью. Вы научитесь обрабатывать ввод с двух устройств одновременно, создавать визуальный курсор и динамически обновлять текст на экране — навык, полезный для любых игровых меню и форм.

Версия 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('block', 'assets/input/block.png');
        this.load.image('rub', 'assets/input/rub.png');
        this.load.image('end', 'assets/input/end.png');
        this.load.bitmapFont('arcade', 'assets/fonts/bitmap/arcade.png', 'assets/fonts/bitmap/arcade.xml');
    }

    create ()
    {
        const chars = [
            [ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J' ],
            [ 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T' ],
            [ 'U', 'V', 'W', 'X', 'Y', 'Z', '.', '-', '<', '>' ]
        ];
        const cursor = { x: 0, y: 0 };
        let name = '';

        const input = this.add.bitmapText(130, 50, 'arcade', 'ABCDEFGHIJ\n\nKLMNOPQRST\n\nUVWXYZ.-').setLetterSpacing(20);

        input.setInteractive();

        const rub = this.add.image(input.x + 430, input.y + 148, 'rub');
        const end = this.add.image(input.x + 482, input.y + 148, 'end');

        const block = this.add.image(input.x - 10, input.y - 2, 'block').setOrigin(0);

        const legend = this.add.bitmapText(80, 260, 'arcade', 'RANK  SCORE   NAME').setTint(0xff00ff);

        this.add.bitmapText(80, 310, 'arcade', '1ST   50000    ').setTint(0xff0000);
        this.add.bitmapText(80, 360, 'arcade', '2ND   40000    ICE').setTint(0xff8200);
        this.add.bitmapText(80, 410, 'arcade', '3RD   30000    GOS').setTint(0xffff00);
        this.add.bitmapText(80, 460, 'arcade', '4TH   20000    HRE').setTint(0x00ff00);
        this.add.bitmapText(80, 510, 'arcade', '5TH   10000    ETE').setTint(0x00bfff);

        const playerText = this.add.bitmapText(560, 310, 'arcade', name).setTint(0xff0000);

        this.input.keyboard.on('keyup', event =>
        {

            if (event.keyCode === 37)
            {
                //  left
                if (cursor.x > 0)
                {
                    cursor.x--;
                    block.x -= 52;
                }
            }
            else if (event.keyCode === 39)
            {
                //  right
                if (cursor.x < 9)
                {
                    cursor.x++;
                    block.x += 52;
                }
            }
            else if (event.keyCode === 38)
            {
                //  up
                if (cursor.y > 0)
                {
                    cursor.y--;
                    block.y -= 64;
                }
            }
            else if (event.keyCode === 40)
            {
                //  down
                if (cursor.y < 2)
                {
                    cursor.y++;
                    block.y += 64;
                }
            }
            else if (event.keyCode === 13 || event.keyCode === 32)
            {
                //  Enter or Space
                if (cursor.x === 9 && cursor.y === 2 && name.length > 0)
                {
                    //  Submit
                }
                else if (cursor.x === 8 && cursor.y === 2 && name.length > 0)
                {
                    //  Rub
                    name = name.substr(0, name.length - 1);

                    playerText.text = name;
                }
                else if (name.length < 3)
                {
                    //  Add
                    name = name.concat(chars[cursor.y][cursor.x]);

                    playerText.text = name;
                }
            }

        });

        input.on('pointermove', (pointer, x, y) =>
        {

            const cx = Phaser.Math.Snap.Floor(x, 52, 0, true);
            const cy = Phaser.Math.Snap.Floor(y, 64, 0, true);
            const char = chars[cy][cx];

            cursor.x = cx;
            cursor.y = cy;

            block.x = input.x - 10 + (cx * 52);
            block.y = input.y - 2 + (cy * 64);

        }, this);

        input.on('pointerup', (pointer, x, y) =>
        {

            const cx = Phaser.Math.Snap.Floor(x, 52, 0, true);
            const cy = Phaser.Math.Snap.Floor(y, 64, 0, true);
            const char = chars[cy][cx];

            cursor.x = cx;
            cursor.y = cy;

            block.x = input.x - 10 + (cx * 52);
            block.y = input.y - 2 + (cy * 64);

            if (char === '<' && name.length > 0)
            {
                //  Rub
                name = name.substr(0, name.length - 1);

                playerText.text = name;
            }
            else if (char === '>' && name.length > 0)
            {
                //  Submit
            }
            else if (name.length < 3)
            {
                //  Add
                name = name.concat(char);

                playerText.text = name;
            }

        }, this);

    }
}

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

const game = new Phaser.Game(config);

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

В методе create() мы создаём основу для нашего интерфейса ввода. Сначала определяется двумерный массив chars, представляющий собой сетку символов, аналогичную старым аркадным автоматам.

Затем создаётся объект bitmapText, который отображает все эти символы на экране в три строки. Важно, что этот текстовый объект также получает интерактивность с помощью метода setInteractive(), что позволит нам обрабатывать события мыши.

Рядом с текстом добавляются две иконки спрайтов: rub (для удаления символа) и end (для подтверждения ввода). Спрайт block служит визуальным курсором, выделяющим текущий выбранный символ. Он позиционируется относительно текстового поля.

const chars = [
    [ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J' ],
    [ 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T' ],
    [ 'U', 'V', 'W', 'X', 'Y', 'Z', '.', '-', '<', '>' ]
];
const cursor = { x: 0, y: 0 };
let name = '';

const input = this.add.bitmapText(130, 50, 'arcade', 'ABCDEFGHIJ\n\nKLMNOPQRST\n\nUVWXYZ.-').setLetterSpacing(20);
input.setInteractive();

const rub = this.add.image(input.x + 430, input.y + 148, 'rub');
const end = this.add.image(input.x + 482, input.y + 148, 'end');
const block = this.add.image(input.x - 10, input.y - 2, 'block').setOrigin(0);

Обработка ввода с клавиатуры

Логика управления с клавиатуры подписывается на глобальное событие keyup с помощью this.input.keyboard.on(). Обработчик анализирует event.keyCode для определения нажатой клавиши.

Стрелки (keyCode 37-40) перемещают внутренний объект cursor и визуальный спрайт block по сетке символов, проверяя границы (от 0 до 9 по X и от 0 до 2 по Y). Клавиши Enter (13) и Space (32) выполняют действие в зависимости от текущей позиции курсора: - Если курсор на символе '>' (позиция 9,2) и имя не пустое — можно отправить имя (логика отправки не реализована в примере). - Если курсор на символе '<' (позиция 8,2) и имя не пустое — удаляется последний символ из строки name. - В любой другой позиции и если длина имени меньше 3 символов — выбранный символ добавляется к имени. После любого изменения строки name обновляется текст playerText.

this.input.keyboard.on('keyup', event => {
    if (event.keyCode === 37) { // left
        if (cursor.x > 0) {
            cursor.x--;
            block.x -= 52;
        }
    }
    else if (event.keyCode === 39) { // right
        if (cursor.x < 9) {
            cursor.x++;
            block.x += 52;
        }
    }
    // ... обработка up/down ...
    else if (event.keyCode === 13 || event.keyCode === 32) { // Enter or Space
        if (cursor.x === 9 && cursor.y === 2 && name.length > 0) {
            // Submit
        }
        else if (cursor.x === 8 && cursor.y === 2 && name.length > 0) {
            // Rub
            name = name.substr(0, name.length - 1);
            playerText.text = name;
        }
        else if (name.length < 3) {
            // Add
            name = name.concat(chars[cursor.y][cursor.x]);
            playerText.text = name;
        }
    }
});

Обработка ввода с помощью мыши

Поскольку текстовый объект input интерактивен, к нему можно привязать события мыши. Событие pointermove обновляет позицию курсора и блока при движении мыши над сеткой символов. Ключевую роль здесь играет утилита Phaser.Math.Snap.Floor. Она "привязывает" координаты мыши (`x,y`) к ячейкам сетки с заданным шагом (52 пикселя по горизонтали, 64 по вертикали), что позволяет точно определить, над каким символом находится указатель.

input.on('pointermove', (pointer, x, y) => {
    const cx = Phaser.Math.Snap.Floor(x, 52, 0, true);
    const cy = Phaser.Math.Snap.Floor(y, 64, 0, true);

    cursor.x = cx;
    cursor.y = cy;
    block.x = input.x - 10 + (cx * 52);
    block.y = input.y - 2 + (cy * 64);
}, this);

Событие pointerup обрабатывает клик. Логика аналогична обработке Enter/Space в клавиатурном управлении, но решение о действии принимается на основе символа (char), полученного из массива chars по вычисленным индексам.

input.on('pointerup', (pointer, x, y) => {
    const cx = Phaser.Math.Snap.Floor(x, 52, 0, true);
    const cy = Phaser.Math.Snap.Floor(y, 64, 0, true);
    const char = chars[cy][cx];
    // ... обновление cursor и block ...
    if (char === '<' && name.length > 0) {
        // Rub
        name = name.substr(0, name.length - 1);
        playerText.text = name;
    }
    else if (char === '>' && name.length > 0) {
        // Submit
    }
    else if (name.length < 3) {
        // Add
        name = name.concat(char);
        playerText.text = name;
    }
}, this);

Визуализация и отладка

Пример также создаёт статичную таблицу лидеров с помощью bitmapText, чтобы имитировать игровой контекст. Текст для игрока (playerText) инициализируется пустой строкой name и обновляется при любом изменении. Использование setTint позволяет легко раскрашивать текст в нужные цвета, соответствующие ретро-стилю.

Обратите внимание на синхронизацию состояния. Объект cursor и спрайт block являются единым источником истины для позиции выбора. Они обновляются как событиями клавиатуры, так и событиями мыши, что обеспечивает согласованное поведение интерфейса независимо от способа ввода.

const playerText = this.add.bitmapText(560, 310, 'arcade', name).setTint(0xff0000);
// ...
playerText.text = name; // Обновление при изменении

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

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