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

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

Версия 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.Chebyshev(
                origin.x,
                origin.y,
                tile.x,
                tile.y
            );

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

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

        // 8 directions

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

        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);

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

Класс Example расширяет Phaser.Scene. В методе preload() загружаются необходимые ресурсы: изображение тайлов (tiles), тайловая карта в формате CSV (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() создаётся тайловая карта из CSV. Важно указать размер тайла, так как в CSV эта информация не хранится. Затем создаются анимации ходьбы для персонажа в четыре стороны. Сам персонаж добавляется как физический спрайт с помощью this.physics.add.sprite. Камера настраивается на границы карты и начинает следовать за игроком.

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);

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

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

Управление персонажем и физика

Метод updatePlayer() вызывается каждый кадр и обрабатывает ввод с клавиатуры. Скорость тела персонажа (this.player.body) задаётся отдельно по осям X и Y. В зависимости от нажатых клавиш воспроизводится соответствующая анимация. Анимации влево/вправо имеют приоритет над анимациями вверх/вниз.

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);
    }

    if (this.cursors.up.isDown)
    {
        this.player.body.setVelocityY(-100);
    }
    else if (this.cursors.down.isDown)
    {
        this.player.body.setVelocityY(100);
    }
    // ... логика анимаций
}

Сердце эффекта: расстояние Чебышёва и обновление тайлов

Ключевая логика находится в updateMap(). Сначала определяется тайл (origin), на котором в данный момент находится игрок, используя this.map.getTileAtWorldXY. Затем для каждого тайла на карте (this.map.forEachTile) вычисляется расстояние Чебышёва до origin. Это расстояние равно максимуму из разностей координат по осям. Прозрачность тайла (alpha) устанавливается обратно пропорциональной этому расстоянию, создавая плавный градиент затемнения.

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

    this.map.forEachTile(tile =>
    {
        const dist = Phaser.Math.Distance.Chebyshev(
            origin.x,
            origin.y,
            tile.x,
            tile.y
        );
        tile.setAlpha(1 - 0.1 * dist);
    });
}

Конфигурация игры

Объект config определяет настройки игры Phaser. Важные моменты: pixelArt: true включает сглаживание для пиксель-арта, а в физическом движке выбран 'arcade' для простой физики спрайта игрока.

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

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

Этот пример — отличная основа для механик освещения, тумана войны или области видимости. Для экспериментов попробуйте заменить расстояние Чебышёва на Манхэттенское (Phaser.Math.Distance.Manhattan) или Евклидово (Phaser.Math.Distance.Between) для другую формы "светового пятна". Можно добавить пороговое значение, после которого тайлы становятся полностью невидимыми, или изменить не прозрачность, а оттенок (tint) тайлов.