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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    tiles = [ 7, 7, 7, 6, 6, 6, 0, 0, 0, 1, 1, 2, 3, 4, 5 ];
    distance = 0;
    mapHeight = 37;
    mapWidth = 51;
    sx = 0;
    text;
    map;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('tiles', 'assets/tilemaps/tiles/muddy-ground.png');
        this.load.bitmapFont('nokia16', 'assets/fonts/bitmap/nokia16.png', 'assets/fonts/bitmap/nokia16.xml');
    }

    create ()
    {
        const mapData = [];

        for (let y = 0; y < this.mapHeight; y++)
        {
            const row = [];

            for (let x = 0; x < this.mapWidth; x++)
            {
                //  Scatter the tiles so we get more mud and less stones
                const tileIndex = Phaser.Math.RND.weightedPick(this.tiles);

                row.push(tileIndex);
            }

            mapData.push(row);
        }

        this.map = this.make.tilemap({ data: mapData, tileWidth: 16, tileHeight: 16 });

        const tileset = this.map.addTilesetImage('tiles');
        const layer = this.map.createLayer(0, tileset, 0, 0);

        this.text = this.add.bitmapText(0, 0, 'nokia16').setScrollFactor(0);
    }

    update (time, delta)
    {
        //  Any speed as long as 16 evenly divides by it
        this.sx += 4;

        this.distance += this.sx;

        this.text.setText(`Distance: ${this.distance}px`);

        if (this.sx === 16)
        {
            //  Reset and create new strip

            let tile;
            let prev;

            for (let y = 0; y < this.mapHeight; y++)
            {
                for (let x = 1; x < this.mapWidth; x++)
                {
                    tile = this.map.getTileAt(x, y);
                    prev = this.map.getTileAt(x - 1, y);

                    prev.index = tile.index;

                    if (x === this.mapWidth - 1)
                    {
                        tile.index = Phaser.Math.RND.weightedPick(this.tiles);
                    }
                }
            }

            this.sx = 0;
        }

        this.cameras.main.scrollX = this.sx;
    }
}

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

const game = new Phaser.Game(config);

Подготовка данных: взвешенная случайность

Ключ к разнообразному миру — в алгоритме выбора тайлов. Вместо равномерного распределения используется взвешенный выбор, что позволяет контролировать частоту появления разных типов местности.

Массив tiles содержит индексы тайлов. Повторяющиеся значения увеличивают шанс их выбора. Например, индекс `7встречается три раза, а индекс5` — всего один. Это создает "грязную" основу с редкими вкраплениями камней.

В методе create создается двумерный массив mapData, который заполняется с помощью Phaser.Math.RND.weightedPick. Этот метод автоматически учитывает вес каждого элемента в массиве.

// Массив весов тайлов. Индекс 7 имеет самый большой вес (3), индекс 5 — самый маленький (1).
tiles = [ 7, 7, 7, 6, 6, 6, 0, 0, 0, 1, 1, 2, 3, 4, 5 ];

// Взвешенный выбор индекса тайла для конкретной ячейки (x, y)
const tileIndex = Phaser.Math.RND.weightedPick(this.tiles);

Создание и настройка тайловой карты

Phaser позволяет создавать карту напрямую из двумерного массива чисел, где каждое число — индекс тайла в tileset'е. Это идеально подходит для процедурной генерации.

Созданная карта this.map имеет фиксированные ширину и высоту в тайлах (mapWidth, mapHeight). Она отрисовывается один раз при создании сцены. Важно, что физический скролл камеры позже будет имитировать движение, но сами данные карты мы будем модифицировать.

// Создание тайловой карты из готового массива данных
this.map = this.make.tilemap({ data: mapData, tileWidth: 16, tileHeight: 16 });

// Добавление набора тайлов (tileset) и создание слоя для отрисовки
const tileset = this.map.addTilesetImage('tiles');
const layer = this.map.createLayer(0, tileset, 0, 0);

Движение и логика "бегущего окна"

Вся магия происходит в методе update. Переменная this.sx накапливает смещение камеры с каждым кадром. Когда это смещение достигает размера одного тайла (16 пикселей), карта "сдвигается" на один столбец тайлов влево, а самый правый столбец заполняется новыми случайными тайлами.

Это эффективно, потому что изменение индексов существующих тайлов (prev.index = tile.index) и генерация всего одного нового столбца требуют гораздо меньше ресурсов, чем пересоздание или перемещение всей графики.

// Скорость скролла. Должна нацело делить размер тайла (16).
this.sx += 4;

// Условие для сдвига данных карты
if (this.sx === 16)
{
    // ... логика сдвига тайлов ...
    this.sx = 0; // Сброс накопленного смещения
}

// Применение смещения к камере для визуального скролла
this.cameras.main.scrollX = this.sx;

Алгоритм сдвига столбцов тайлов

Сердце механизма — двойной цикл, который проходит по всем строкам карты. Для каждой ячейки (кроме самой левой) индекс тайла копируется из соседа справа. В результате все тайлы сдвигаются на одну позицию влево. Крайний правый столбец, который "освобождается", заполняется новыми случайными тайлами.

Операция выполняется только раз в несколько кадров (когда накопилось смещение в 16px), что делает ее очень производительной.

for (let y = 0; y < this.mapHeight; y++)
{
    for (let x = 1; x < this.mapWidth; x++)
    {
        // Получаем текущий тайл и его левого соседа
        tile = this.map.getTileAt(x, y);
        prev = this.map.getTileAt(x - 1, y);

        // Копируем индекс от текущего к предыдущему (сдвиг влево)
        prev.index = tile.index;

        // Для последнего столбца генерируем новый тайл
        if (x === this.mapWidth - 1)
        {
            tile.index = Phaser.Math.RND.weightedPick(this.tiles);
        }
    }
}

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

Представленная техника — отличная основа для игр с боковым скроллингом. Она эффективно использует ресурсы, создавая бесконечный и уникальный мир. Для экспериментов попробуйте изменить логику генерации правого столбца: добавьте шаблоны для создания дорог, рек или пещер. Можно реализовать систему "чунков", где новый столбец подгружается из предварительно сгенерированных секций разной тематики. Также интересно будет привязать скорость скролла this.sx к игровому времени или действиям персонажа.