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

Изометрическая графика придает играм уникальный шарм и ощущение объема. В этом примере мы разберем, как динамически генерировать и изменять изометрический ландшафт, используя карту высот и геометрические примитивы Phaser. Вы научитесь работать с `add.isobox`, управлять высотой объектов и создавать интерактивный мир, реагирующий на ввод пользователя.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    waterHeight = 60;
    maxHeight = 120;
    offsetY = 90;
    spacing = 12;
    size = 20;
    gridHeight = 46;
    gridWidth = 39;
    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('noise', 'assets/tests/noise.png');

        // this.load.image('noise', 'assets/tests/heightmap.png');
    }

    create ()
    {
        this.heightmap = this.textures.createCanvas('map', 512, 512);

        this.heightmap.draw(0, 0, this.textures.get('noise').getSourceImage());

        let ox = this.size;
        let r = 0;
        const h = this.size;

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

            for (let x = 0; x < this.gridWidth - r; x++)
            {
                const tile = this.add.isobox(ox + x * this.size, this.offsetY + y * this.spacing, this.size, h, 0x8dcb0e, 0x3f8403, 0x63a505);

                row.push(tile);
            }

            r++;
            ox += this.size / 2;

            if (r === 2)
            {
                r = 0;
                ox = this.size;
            }

            this.land.push(row);
        }

        this.updateLand();

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

    update ()
    {
        let down = false;

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

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

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

            if (this.px === 512)
            {
                this.px = 0;
            }

            down = true;
        }

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

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

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

            if (this.py === 512)
            {
                this.py = 0;
            }

            down = true;
        }

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

    updateLand ()
    {
        let r = 0;

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

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

                const h = (Math.max(this.color.r, this.color.g, this.color.b) / 255) * this.maxHeight;

                if (h < this.waterHeight)
                {
                    this.land[y][x].setFillStyle(0x00b9f2, 0x016fce, 0x028fdf);
                }
                else if (h === this.maxHeight)
                {
                    this.land[y][x].setFillStyle(0xffe31f, 0xf2a022, 0xf8d80b);
                }
                else
                {
                    this.land[y][x].setFillStyle(0x8dcb0e, 0x3f8403, 0x63a505);
                }

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

            r++;

            if (r === 2)
            {
                r = 0;
            }
        }
    }
}

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

const game = new Phaser.Game(config);

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

Класс Example расширяет Phaser.Scene. В методе preload загружается изображение шума, которое будет использовано в качестве карты высот. Важно: изображение загружается с удаленного URL, что демонстрирует гибкость загрузчика Phaser.

this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('noise', 'assets/tests/noise.png');

В методе create создается динамическая текстура heightmap на основе загруженного изображения. Это позволяет манипулировать пиксельными данными в реальном времени.

this.heightmap = this.textures.createCanvas('map', 512, 512);
this.heightmap.draw(0, 0, this.textures.get('noise').getSourceImage());

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

Сетка ландшафта строится с помощью вложенных циклов. Ключевой метод — this.add.isobox, который создает изометрический параллелепипед (изобокс). Параметры: координаты X, Y, ширина, высота и три цвета для граней (светлая, темная и средняя).

const tile = this.add.isobox(ox + x * this.size, this.offsetY + y * this.spacing, this.size, h, 0x8dcb0e, 0x3f8403, 0x63a505);

Логика смещения ox и уменьшения ширины строки this.gridWidth - r создает характерный сдвиг изометрической сетки. Каждый ряд сохраняется в массив this.land для дальнейшего управления.

Динамическое обновление ландшафта

Метод updateLand — сердце примера. Для каждого изобокса в сетке вычисляется цвет пикселя на карте высот с помощью this.heightmap.getPixel. По значению цвета определяется высота и тип поверхности: вода, трава или вершина.

const h = (Math.max(this.color.r, this.color.g, this.color.b) / 255) * this.maxHeight;

В зависимости от высоты вызывается метод setFillStyle для изменения цветов граней изобокса. Высота самого объекта задается через свойство height. Это визуально изменяет его пропорции.

this.land[y][x].setFillStyle(0x00b9f2, 0x016fce, 0x028fdf);
this.land[y][x].height = h;

Обработка ввода и навигация

В методе update отслеживается состояние клавиш-стрелок с помощью this.cursors. При нажатии изменяются переменные this.px и this.py, которые представляют текущую позицию "камеры" на карте высот. Используется Phaser.Math.Wrap для зацикливания координат, создавая бесконечный ландшафт.

const cx = Phaser.Math.Wrap(this.px + x, 0, 512);

Любое изменение позиции приводит к вызову this.updateLand(), что мгновенно перерисовывает ландшафт, создавая эффект прокрутки.

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

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