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

Изометрическая проекция придаёт 2D-играм глубину и объём, но создание сложного рельефа вручную — трудоёмкая задача. В этой статье мы разберём пример из официальной документации Phaser, который автоматически генерирует изометрический ландшафт на основе обычного изображения. Это позволяет быстро создавать разнообразные земли, холмы и впадины, используя в качестве источника данных простое изображение в оттенках серого. Вы научитесь работать с изобоксами (`isobox`), создавать динамические текстуры-канвасы для чтения пикселей и применять математику для корректного отображения и обновления изометрической сетки. Этот подход полезен для прототипирования стратегий, симуляторов или игр с видом сверху.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    imageHeight = 0;
    imageWidth = 0;
    maxHeight = 120;
    spacing = 12;
    centerY = -200;
    centerX = 400;
    tileHeightHalf = 10;
    tileWidthHalf = 15;
    size = 24;
    gridHeight = 54;
    gridWidth = 54;
    cursors;
    py = 0;
    px = 0;
    land = [];
    heightmap;
    color = new Phaser.Display.Color();

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('pic', 'assets/tests/terrain/terrain2.png');
    }

    create ()
    {
        const src = this.textures.get('pic').getSourceImage();

        this.imageWidth = src.width;
        this.imageHeight = src.height;

        this.heightmap = this.textures.createCanvas('map', this.imageWidth, this.imageHeight);

        this.heightmap.draw(0, 0, src);

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

            for (let x = 0; x < this.gridWidth; x++)
            {
                const tx = (x - y) * this.tileWidthHalf;
                const ty = (x + y) * this.tileHeightHalf;

                const tile = this.add.isobox(this.centerX + tx, this.centerY + ty, this.size, this.size, 0x8dcb0e, 0x3f8403, 0x63a505);

                tile.setDepth(this.centerY + ty);

                row.push(tile);
            }

            this.land.push(row);
        }

        this.updateLand();

        this.cursors = this.input.keyboard.createCursorKeys();
    }

    update ()
    {
        let down = false;

        if (this.cursors.left.isDown)
        {
            this.px--;
            this.py++;

            down = true;
        }
        else if (this.cursors.right.isDown)
        {
            this.px++;
            this.py--;

            down = true;
        }

        if (this.cursors.up.isDown)
        {
            this.px--;
            this.py--;

            down = true;
        }
        else if (this.cursors.down.isDown)
        {
            this.px++;
            this.py++;

            down = true;
        }

        if (this.px === this.imageWidth)
        {
            this.px = 0;
        }
        else if (this.px < 0)
        {
            this.px = this.imageWidth;
        }

        if (this.py === this.imageHeight)
        {
            this.py = 0;
        }
        else if (this.py < 0)
        {
            this.py = this.imageHeight;
        }

        if (down)
        {
            this.updateLand();
        }
    }

    updateLand ()
    {
        for (let y = 0; y < this.gridHeight; y++)
        {
            for (let x = 0; x < this.gridWidth; x++)
            {
                const cx = Phaser.Math.Wrap(this.px + x, 0, this.imageWidth);
                const cy = Phaser.Math.Wrap(this.py + y, 0, this.imageHeight);

                this.heightmap.getPixel(cx, cy, this.color);

                const h = this.color.v * this.maxHeight;
                const top = this.color.color;
                const left = this.color.darken(30).color;
                const right = this.color.lighten(15).color;

                this.land[y][x].setFillStyle(top, left, right);

                this.land[y][x].height = h;
            }
        }
    }
}

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

const game = new Phaser.Game(config);

Подготовка данных: карта высот на Canvas

Вместо того чтобы хранить высоту каждой ячейки в массиве, пример использует изображение в качестве карты высот. Яркость каждого пикселя (значение `v` в HSV-модели) определяет высоту будущего изобокса.

В методе preload загружается исходное изображение. В create на его основе создаётся динамическая текстура типа Canvas, куда это изображение отрисовывается. Этот канвас (heightmap) будет использоваться для быстрого чтения цвета пикселей.

this.heightmap = this.textures.createCanvas('map', this.imageWidth, this.imageHeight);
this.heightmap.draw(0, 0, src);

Таким образом, this.heightmap становится нашим источником данных о высоте, к которому можно обращаться через метод getPixel.

Построение изометрической сетки изобоксов

Сердце примера — создание сетки изобоксов. Изобокс (isobox) — это примитив Phaser, представляющий собой изометрический параллелепипед (куб в изометрической проекции). Он имеет три грани: верхнюю, левую и правую, каждой из которых можно задать свой цвет.

Сетка создаётся вложенными циклами. Ключевой момент — расчёт изометрических координат (tx, ty) для каждого блока. Формула преобразует координаты сетки (x, y) в экранные координаты с учётом изометрии.

const tx = (x - y) * this.tileWidthHalf;
const ty = (x + y) * this.tileHeightHalf;
const tile = this.add.isobox(this.centerX + tx, this.centerY + ty, this.size, this.size, 0x8dcb0e, 0x3f8403, 0x63a505);
tile.setDepth(this.centerY + ty);

Метод setDepth устанавливает глубину отрисовки на основе координаты Y, чтобы блоки, находящиеся "дальше" (ниже на экране), перекрывались блоками, которые "ближе".

Чтение карты высот и обновление ландшафта

Функция updateLand отвечает за синхронизацию внешнего вида сетки изобоксов с данными на карте высот. Она проходит по всем ячейкам сетки, для каждой вычисляет соответствующую координату на изображении-карте и получает цвет пикселя.

Важно: используется Phaser.Math.Wrap, чтобы координаты на карте зацикливались. Это создаёт эффект бесконечного или повторяющегося ландшафта при прокрутке.

const cx = Phaser.Math.Wrap(this.px + x, 0, this.imageWidth);
const cy = Phaser.Math.Wrap(this.py + y, 0, this.imageHeight);
this.heightmap.getPixel(cx, cy, this.color);

Полученный цвет анализируется. Его значение яркости (`v) масштабируется наmaxHeightи задаётся как свойствоheight` изобокса. Затем на основе этого цвета генерируются оттенки для трёх граней блока.

const h = this.color.v * this.maxHeight;
const top = this.color.color;
const left = this.color.darken(30).color;
const right = this.color.lighten(15).color;
this.land[y][x].setFillStyle(top, left, right);
this.land[y][x].height = h;

Интерактивность: прокрутка ландшафта клавишами

Чтобы продемонстрировать динамическую природу ландшафта, пример добавляет управление с помощью стрелок клавиатуры. В методе update отслеживается состояние клавиш-стрелок.

Логика обработки нажатий изменяет не координаты камеры, а начальную точку считывания (px, py) с карты высот. Это эффективнее, чем физическое перемещение всей сетки объектов. Каждое направление изменяет px и py в соответствии с изометрической проекцией, создавая корректное ощущение движения по диагоналям.

if (this.cursors.left.isDown)
{
    this.px--;
    this.py++;
    down = true;
}

При изменении этих переменных вызывается updateLand(), которая пересчитывает высоты и цвета для всей сетки, создавая анимацию "прокрутки" ландшафта под курсором.

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

Этот пример демонстрирует мощную комбинацию: использование растрового изображения как источника данных и изометрических примитивов Phaser для визуализации. Такой подход открывает двери для процедурной генерации ландшафтов — вы можете генерировать карту высот алгоритмически (шум Перлина, симплекс-шум) и мгновенно видеть результат в игре. Для экспериментов попробуйте: 1. Заменить статическое изображение на динамически генерируемый канвас с шумом. 2. Изменить логику цвета в updateLand, чтобы разные диапазоны высот соответствовали разным биомам (трава, камень, снег). 3. Добавить плавную интерполяцию высоты при изменении px и py, чтобы убрать резкие скачки. 4. Использовать эту технику не для ландшафта, а для визуализации других данных (например, карты влияния или плотности населения в стратегической игре).