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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    TILE_HEIGHT = 32;
    TILE_WIDTH = 16;
    blockSize = 32;
    swatchData;
    fillRight;
    fillLeft;
    fillTop;
    map;
    color = new Phaser.Display.Color(255, 255, 255);
    cursors;
    index = 0;
    shape = null;
    current = 1;
    isDown = false;
    shapes = [];

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('bg1', 'assets/skies/gradient4.png');
        this.load.image('dp', 'assets/swatches/gradient-palettes.png');
    }

    create ()
    {
        this.add.image(0, 0, 'bg1').setOrigin(0);

        //  Create the swatch
        const src = this.textures.get('dp').getSourceImage();
        this.swatchData = this.textures.createCanvas('swatch', src.width, src.height);
        this.swatchData.draw(0, 0, src);

        const swatch = this.add.image(800, 0, 'dp').setOrigin(0).setDepth(1000);

        swatch.setInteractive();

        swatch.on('pointerdown', this.changeColor, this);
        swatch.on('pointermove', this.updateColor, this);

        this.fillTop = this.color.color;
        this.fillLeft = this.color.darken(30).color;
        this.fillRight = this.color.lighten(15).color;



        // this.input.keyboard.on('keydown_C', this.setCircle, this);
        // this.input.keyboard.on('keydown_R', this.setRectangle, this);
        // this.input.keyboard.on('keydown_E', this.setEllipse, this);
        // this.input.keyboard.on('keydown_S', this.setStar, this);
        // this.input.keyboard.on('keydown_L', this.setLine, this);
        // this.input.keyboard.on('keydown_DELETE', this.deleteShape, this);
        // this.input.keyboard.on('keydown_TAB', this.changeShape, this);

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

        this.input.on('pointerdown', this.drawStart, this);
        this.input.on('pointermove', this.drawUpdate, this);
        this.input.on('pointerup', this.drawStop, this);
    }

    update ()
    {
        if (!this.shape)
        {
            return;
        }

        if (this.input.keyboard.checkDown(this.cursors.left, 100))
        {
            this.shape.x -= (this.cursors.left.shiftKey) ? this.TILE_WIDTH * 4 : this.TILE_WIDTH;
        }
        else if (this.input.keyboard.checkDown(this.cursors.right, 100))
        {
            this.shape.x += (this.cursors.right.shiftKey) ? this.TILE_WIDTH * 4 : this.TILE_WIDTH;
        }

        if (this.input.keyboard.checkDown(this.cursors.up, 100))
        {
            this.shape.y -= (this.cursors.up.shiftKey) ? this.TILE_HEIGHT * 4 : this.TILE_HEIGHT;
        }
        else if (this.input.keyboard.checkDown(this.cursors.down, 100))
        {
            this.shape.y += (this.cursors.down.shiftKey) ? this.TILE_HEIGHT * 4 : this.TILE_HEIGHT;
        }
    }

    mapToPx (mapX, mapY)
    {
        const x = (mapX - mapY) * this.TILE_WIDTH;
        const y = (mapX + mapY) * this.TILE_HEIGHT / 2;

        return { x: x, y: y };
    }

    pxToMap (screenX, screenY)
    {
        screenX = Phaser.Math.Snap.Floor(screenX, this.TILE_WIDTH);
        screenY = Phaser.Math.Snap.Floor(screenY, this.TILE_HEIGHT);

        // var x = ((screenX / TILE_WIDTH) + (screenY / TILE_HEIGHT)) / 2;
        // var y = ((screenY / TILE_HEIGHT) - (screenX / TILE_WIDTH)) / 2;

        // var x = screenY / TILE_HEIGHT + screenX / (2 * TILE_WIDTH);
        // var y = screenY / TILE_HEIGHT - screenX / (2 * TILE_WIDTH);

        const x = ((screenY * 2 / this.TILE_HEIGHT) + (screenX / this.TILE_WIDTH)) / 2;
        const y = (screenY * 2 / this.TILE_HEIGHT) - x;

        return { x: x, y: y };
    }

    changeColor (pointer, x, y, event)
    {
        this.swatchData.getPixel(x, y, this.color);

        this.fillTop = this.color.this.color;
        this.fillLeft = this.color.darken(30).color;
        this.fillRight = this.color.lighten(15).color;

        if (this.shape)
        {
            this.shape.setFillStyle(this.fillTop, this.fillLeft, this.fillRight);
        }

        event.stopPropagation();
    }

    updateColor (pointer, x, y, event)
    {
        if (!pointer.isDown)
        {
            return;
        }

        this.swatchData.getPixel(x, y, this.color);

        this.fillTop = this.color.this.color;
        this.fillLeft = this.color.darken(30).color;
        this.fillRight = this.color.lighten(15).color;

        if (this.shape)
        {
            this.shape.setFillStyle(this.fillTop, this.fillLeft, this.fillRight);
        }

        event.stopPropagation();
    }

    deleteShape ()
    {
        if (this.shape)
        {
            this.shape.destroy();
            this.shape = null;
        }
    }

    changeShape ()
    {
        if (this.shapes.length < 2)
        {
            return;
        }

        this.index++;

        if (this.index >= this.shapes.length)
        {
            this.index = 0;
        }

        this.shape = this.shapes[this.index];
    }

    drawStart (pointer)
    {
        if (this.current === 0)
        {
            return;
        }

        this.isDown = true;

        console.log('down', pointer.x, pointer.y);

        let pos = this.pxToMap(pointer.x, pointer.y);

        console.log('map', pos.x, pos.y);

        pos = this.mapToPx(pos.x, pos.y);

        console.log('px', pos.x, pos.y);

        this.shape = this.add.isobox(pos.x, pos.y, this.blockSize, this.blockSize * 0.70, this.fillTop, this.fillLeft, this.fillRight);
    }

    drawUpdate (pointer)
    {
        if (!this.isDown)
        {
            return;
        }

    }

    drawStop ()
    {
        this.isDown = false;

        this.shapes.push(this.shape);

        this.index++;
    }
}

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

const game = new Phaser.Game(config);

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

В методе preload загружаются два изображения: фон (bg1) и палитра цветов (dp). Палитра будет использоваться как интерактивная область для выбора цвета.

В create мы настраиваем сцену. Ключевой момент — создание текстуры-канваса swatchData на основе загруженной палитры. Это позволяет в реальном времени считывать цвет пикселя под курсором.

this.swatchData = this.textures.createCanvas('swatch', src.width, src.height);
this.swatchData.draw(0, 0, src);

Изображение палитры добавляется на сцену и ему назначаются обработчики событий pointerdown и pointermove для смены цвета. Исходный цвет, его затемнённая и осветлённая версии сохраняются в свойствах fillTop, fillLeft и fillRight для последующей заливки граней изобокса.

Также настраиваются слушатели для курсорных клавиш и событий мыши (pointerdown, pointermove, pointerup), которые будут управлять рисованием и перемещением фигур.

Магия изометрических преобразований

Сердце изометрического рендеринга — преобразование координат. В примере реализованы две функции: mapToPx и pxToMap.

mapToPx переводит координаты из изометрической (таile-based) системы в экранные (пиксельные). Это нужно для корректного отображения объекта на экране.

const x = (mapX - mapY) * this.TILE_WIDTH;
const y = (mapX + mapY) * this.TILE_HEIGHT / 2;

pxToMap выполняет обратную операцию: переводит пиксельные координаты указателя мыши обратно в изометрическую систему. Это позволяет точно "привязать" создаваемый объект к изометрической сетке. Для этого используется функция Phaser.Math.Snap.Floor.

screenX = Phaser.Math.Snap.Floor(screenX, this.TILE_WIDTH);
screenY = Phaser.Math.Snap.Floor(screenY, this.TILE_HEIGHT);

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

Создание и управление изобоксами

Основной объект в этом примере — изобокс (isobox), создаваемый с помощью фабрики игровых объектов this.add.isobox.

this.shape = this.add.isobox(pos.x, pos.y, this.blockSize, this.blockSize * 0.70, this.fillTop, this.fillLeft, this.fillRight);

Параметры конструктора: позиция (x, y), ширина, высота и три цвета для заливки верхней, левой и правой граней соответственно. Использование разных цветов создаёт иллюзию объёма и освещения.

Логика рисования проста: 1. В drawStart по клику мыши вычисляется позиция в изометрической сетке и создаётся новый isobox. 2. Созданная фигура сохраняется в массив shapes в методе drawStop. 3. Активная фигура (this.shape) может быть удалена методом deleteShape.

Управление цветом происходит через методы changeColor и updateColor, которые считывают цвет под курсором с палитры и применяют его к активной фигуре с помощью this.shape.setFillStyle.

Интерактивное управление с клавиатуры

Пример демонстрирует плавное управление активной фигурой с клавиатуры. Вся логика перемещения вынесена в метод update.

Для проверки нажатия используется метод this.input.keyboard.checkDown(key, duration), который возвращает true с заданной периодичностью (здесь 100 мс), пока клавиша зажата. Это удобнее, чем обработка событий keydown, так как обеспечивает непрерывное движение.

if (this.input.keyboard.checkDown(this.cursors.left, 100))
{
    this.shape.x -= (this.cursors.left.shiftKey) ? this.TILE_WIDTH * 4 : this.TILE_WIDTH;
}

Код также проверяет, зажата ли клавиша Shift (this.cursors.left.shiftKey). Если да, фигура перемещается быстрее (на 4 тайла за шаг вместо одного). Это простой и эффективный способ реализовать два режима скорости перемещения.

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

Этот пример — отличная основа для создания редактора уровней или инструментов для изометрических игр. Вы можете расширить его функциональность: добавить создание других изометрических примитивов (например, isoTriangle), реализовать выбор фигур по нажатию цифровых клавиш, добавить слои (z-index) или привязку к сетке с разной высотой ячеек. Поэкспериментируйте с изменением соотношения TILE_WIDTH и TILE_HEIGHT для получения различного угла обзора изометрии.