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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    showDebug = false;
    player;
    helpText;
    debugGraphics;
    cursors;
    map;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('tiles', 'assets/tilemaps/tiles/catastrophi_tiles_16.png');
        this.load.tilemapCSV('map', 'assets/tilemaps/csv/catastrophi_level2.csv');
        this.load.spritesheet('player', 'assets/sprites/spaceman.png', { frameWidth: 16, frameHeight: 16 });
    }

    create ()
    {
        // When loading a CSV map, make sure to specify the tileWidth and tileHeight
        this.map = this.make.tilemap({ key: 'map', tileWidth: 16, tileHeight: 16 });
        const tileset = this.map.addTilesetImage('tiles');
        const layer = this.map.createLayer(0, tileset, 0, 0);

        this.anims.create({
            key: 'left',
            frames: this.anims.generateFrameNumbers('player', { start: 8, end: 9 }),
            frameRate: 10,
            repeat: -1
        });
        this.anims.create({
            key: 'right',
            frames: this.anims.generateFrameNumbers('player', { start: 1, end: 2 }),
            frameRate: 10,
            repeat: -1
        });
        this.anims.create({
            key: 'up',
            frames: this.anims.generateFrameNumbers('player', { start: 11, end: 13 }),
            frameRate: 10,
            repeat: -1
        });
        this.anims.create({
            key: 'down',
            frames: this.anims.generateFrameNumbers('player', { start: 4, end: 6 }),
            frameRate: 10,
            repeat: -1
        });

        this.player = this.physics.add.sprite(400, 300, 'player', 1);

        this.cameras.main.setBounds(0, 0, this.map.widthInPixels, this.map.heightInPixels);
        this.cameras.main.startFollow(this.player);

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

    update (time, delta)
    {
        this.updatePlayer();
        this.updateMap();
    }

    updateMap ()
    {
        const origin = this.map.getTileAtWorldXY(this.player.x, this.player.y);

        this.map.forEachTile(tile =>
        {
            const dist = Phaser.Math.Distance.Snake(
                origin.x,
                origin.y,
                tile.x,
                tile.y
            );

            tile.setAlpha(1 - 0.1 * dist);
        });
    }

    updatePlayer ()
    {
        this.player.body.setVelocity(0);

        // Snake movement is 4 directions only

        if (this.cursors.left.isDown)
        {
            this.player.body.setVelocityX(-100);
        }
        else if (this.cursors.right.isDown)
        {
            this.player.body.setVelocityX(100);
        }
        else if (this.cursors.up.isDown)
        {
            this.player.body.setVelocityY(-100);
        }
        else if (this.cursors.down.isDown)
        {
            this.player.body.setVelocityY(100);
        }

        // Update the animation last and give left/right animations precedence over up/down animations
        if (this.cursors.left.isDown)
        {
            this.player.anims.play('left', true);
        }
        else if (this.cursors.right.isDown)
        {
            this.player.anims.play('right', true);
        }
        else if (this.cursors.up.isDown)
        {
            this.player.anims.play('up', true);
        }
        else if (this.cursors.down.isDown)
        {
            this.player.anims.play('down', true);
        }
        else
        {
            this.player.anims.stop();
        }
    }
}

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

const game = new Phaser.Game(config);

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

В методе preload загружаются необходимые ресурсы: tileset (набор тайлов), tilemap в формате CSV и спрайтшит для анимации персонажа. CSV-формат карты представляет собой простую таблицу, где каждое число соответствует индексу тайла.

preload ()
{
    this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
    this.load.image('tiles', 'assets/tilemaps/tiles/catastrophi_tiles_16.png');
    this.load.tilemapCSV('map', 'assets/tilemaps/csv/catastrophi_level2.csv');
    this.load.spritesheet('player', 'assets/sprites/spaceman.png', { frameWidth: 16, frameHeight: 16 });
}

В create создаётся тайловая карта. Важно указать tileWidth и tileHeight, так как при загрузке из CSV эта информация не содержится в файле. После создания слоя карты настраиваются анимации персонажа для всех четырёх направлений.

create ()
{
    this.map = this.make.tilemap({ key: 'map', tileWidth: 16, tileHeight: 16 });
    const tileset = this.map.addTilesetImage('tiles');
    const layer = this.map.createLayer(0, tileset, 0, 0);
    // ... создание анимаций
    this.player = this.physics.add.sprite(400, 300, 'player', 1);
}

Камера привязывается к границам карты и начинает следовать за персонажем, что обеспечивает скроллинг большого игрового пространства.

Управление персонажем и анимация

Логика движения и анимации вынесена в метод updatePlayer, который вызывается каждый кадр в update. Движение реализовано строго по четырём направлениям с помощью курсорных клавиш. Физическое тело персонажа получает скорость через setVelocity. Обратите внимание на порядок проверки анимаций: лево/право имеют приоритет над верхом/низом, что является стандартным подходом для 4-стороннего движения.

updatePlayer ()
{
    this.player.body.setVelocity(0);

    if (this.cursors.left.isDown)
    {
        this.player.body.setVelocityX(-100);
    }
    else if (this.cursors.right.isDown)
    {
        this.player.body.setVelocityX(100);
    }
    // ... проверка up и down

    if (this.cursors.left.isDown)
    {
        this.player.anims.play('left', true);
    }
    else if (this.cursors.right.isDown)
    {
        this.player.anims.play('right', true);
    }
    // ... обновление анимаций для up, down и остановка
}

Сердце эффекта: расстояние "змейка" и обновление тайлов

Ключевая механика находится в методе updateMap. В начале каждого кадра определяется тайл, на котором в мировых координатах находится персонаж, с помощью this.map.getTileAtWorldXY.

const origin = this.map.getTileAtWorldXY(this.player.x, this.player.y);

Затем для каждого тайла карты вычисляется расстояние по формуле "змейки" (Snake distance), также известное как манхэттенское или городское расстояние. Оно равно сумме абсолютных разностей координат по осям X и Y. В Phaser для этого есть готовая функция Phaser.Math.Distance.Snake.

const dist = Phaser.Math.Distance.Snake(
    origin.x,
    origin.y,
    tile.x,
    tile.y
);

На основе этого расстояния вычисляется прозрачность (alpha) тайла. Формула 1 - 0.1 * dist делает так, что тайлы, находящиеся дальше от персонажа, становятся более прозрачными. Коэффициент 0.1 задаёт скорость затухания.

tile.setAlpha(1 - 0.1 * dist);

Метод this.map.forEachTile проходит по всем тайлам слоя и применяет эту логику, создавая плавный градиент прозрачности вокруг игрока.

Настройка игры и конфигурация

Конфигурационный объект игры задаёт основные параметры. Ключевые настройки для этого примера: * pixelArt: true — включает сглаживание текстур для пиксель-арта. * physics: { default: 'arcade' } — подключает простую аркадную физику, которая управляет движением спрайта персонажа. * scene: Example — указывает класс сцены для запуска.

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

const game = new Phaser.Game(config);

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

Пример наглядно показывает, как комбинация простых инструментов Phaser — тайловых карт, физики и математических функций — позволяет создавать динамические визуальные эффекты. Расстояние "змейки" идеально подходит для пошаговых или плиточных миров. Для экспериментов попробуйте заменить Distance.Snake на Distance.Chebyshev (расстояние Чебышёва) для диагональной области влияния или на Distance.Euclidean для круглой. Можно менять формулу прозрачности, добавлять пороговые значения для создания чёткой границы или применять эффект к другим свойствам тайлов, например, к оттенку.