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

В 2D-играх часто возникает задача выделить игрока или объект на сложном фоне, особенно в тёмных локациях. Обычное затемнение экрана и круглая область освещения — классический приём. В этом примере мы разберём, как создать эффект движущегося «фонарика» или «прожектора», который следует за персонажем, используя Tilemap для уровня, Arcade Physics для движения и коллизий, и, что самое важное, — объект `RenderTexture` в Phaser для реализации маскирования. Этот подход не требует шейдеров и работает на стандартном Canvas рендерере, что делает его отличным решением для кроссплатформенных проектов.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    constructor ()
    {
        super('example');
   }

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('ground_1x1', 'assets/tilemaps/tiles/ground_1x1.png');
        this.load.spritesheet('coin', 'assets/sprites/coin.png', { frameWidth: 32, frameHeight: 32 });
        this.load.tilemapTiledJSON('map', 'assets/tilemaps/maps/tile-collision-test.json');
        this.load.image('player', 'assets/sprites/phaser-dude.png');
        this.load.image('mask', 'assets/sprites/mask1.png');
    }

    create ()
    {
        const map = this.make.tilemap({ key: 'map' });

        const groundTiles = map.addTilesetImage('ground_1x1');
        const coinTiles = map.addTilesetImage('coin');

        const backgroundLayer = map.createLayer('Background Layer', groundTiles, 0, 0);
        const groundLayer = map.createLayer('Ground Layer', groundTiles, 0, 0);
        const coinLayer = map.createLayer('Coin Layer').setVisible(false);

        //  Our fake RenderTexture mask goes here, above the layers, but below the coins / player
        //  Important: We only want it to be the size of the canvas, _not_ the map!
        this.rt = this.add.renderTexture(0, 0, this.scale.width, this.scale.height);

        //  Make sure it doesn't scroll with the camera
        this.rt.setOrigin(0, 0);
        this.rt.setScrollFactor(0, 0);

        const coins = [];

        coinLayer.forEachTile(tile => {

            if (tile.index === 26)
            {
                const coin = this.physics.add.image(tile.pixelX + 16, tile.pixelY + 16, 'coin');
                coin.body.allowGravity = false;
                coins.push(coin);
            }

        });

        groundLayer.setCollisionBetween(1, 25);

        this.player = this.physics.add.sprite(80, 70, 'player').setBounce(0.1);

        this.physics.add.collider(this.player, groundLayer);
        this.physics.add.overlap(this.player, coins, (p, c) => {
            c.visible = false;
        });

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

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

    update ()
    {
        this.player.body.setVelocityX(0);

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

        if ((this.cursors.space.isDown || this.cursors.up.isDown) && this.player.body.onFloor())
        {
            this.player.body.setVelocityY(-300);
        }

        //  Draw the spotlight on the player
        const cam = this.cameras.main;

        //  Clear the RenderTexture
        this.rt.clear();

        //  Fill it in black
        this.rt.fill(0x000000);

        //  Erase the 'mask' texture from it based on the player position
        //  We - 107, because the mask image is 213px wide, so this puts it on the middle of the player
        //  We then minus the scrollX/Y values, because the RenderTexture is pinned to the screen and doesn't scroll
        this.rt.erase('mask', (this.player.x - 107) - cam.scrollX, (this.player.y - 107) - cam.scrollY);
    }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#2d2d2d',
    parent: 'phaser-example',
    physics: {
        default: 'arcade',
        arcade: { gravity: { y: 300 } }
    },
    scene: Example
};

const game = new Phaser.Game(config);

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

В методе preload загружаются все необходимые ресурсы. Обратите внимание на структуру: отдельно загружаются тайлы для земли и монет, сам файл карты в формате JSON (экспортированный из Tiled) и спрайты для игрока и маски (изображения прожектора).

this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('ground_1x1', 'assets/tilemaps/tiles/ground_1x1.png');
this.load.spritesheet('coin', 'assets/sprites/coin.png', { frameWidth: 32, frameHeight: 32 });
this.load.tilemapTiledJSON('map', 'assets/tilemaps/maps/tile-collision-test.json');
this.load.image('player', 'assets/sprites/phaser-dude.png');
this.load.image('mask', 'assets/sprites/mask1.png');

Важно: изображение mask — это не маска в традиционном смысле графического API, а текстура (чаще всего с градиентом от прозрачного в центре к чёрному по краям), которая будет использоваться для «вырезания» области освещения из затемнённого RenderTexture.

Создание карты, слоёв и физических тел

В методе create создаётся tilemap и три слоя: фоновый, основной (земля) и слой монет. Слой монет сразу делается невидимым (setVisible(false)), потому что монеты будут представлены отдельными физическими спрайтами для обработки столкновений.

const map = this.make.tilemap({ key: 'map' });
const groundTiles = map.addTilesetImage('ground_1x1');
const coinTiles = map.addTilesetImage('coin');
const backgroundLayer = map.createLayer('Background Layer', groundTiles, 0, 0);
const groundLayer = map.createLayer('Ground Layer', groundTiles, 0, 0);
const coinLayer = map.createLayer('Coin Layer').setVisible(false);

Затем происходит важная часть: итерация по всем тайлам в невидимом слое монет. Тайлы с индексом 26 (конкретная монетка в tileset) заменяются на динамические физические спрайты. Это позволяет использовать мощь Arcade Physics для обработки сбора предметов, в отличие от статических тайлов.

const coins = [];
coinLayer.forEachTile(tile => {
    if (tile.index === 26)
    {
        const coin = this.physics.add.image(tile.pixelX + 16, tile.pixelY + 16, 'coin');
        coin.body.allowGravity = false;
        coins.push(coin);
    }
});

Далее настраивается столкновение игрока с землёй и сбор монет через overlap.

groundLayer.setCollisionBetween(1, 25);
this.player = this.physics.add.sprite(80, 70, 'player').setBounce(0.1);
this.physics.add.collider(this.player, groundLayer);
this.physics.add.overlap(this.player, coins, (p, c) => {
    c.visible = false;
});

Сердце эффекта: RenderTexture как динамическая маска

Ключевой объект — this.rt, созданный как RenderTexture размером с холст игры, а не с карту. Это важно, потому что маска должна быть привязана к экрану, а не миру.

this.rt = this.add.renderTexture(0, 0, this.scale.width, this.scale.height);
this.rt.setOrigin(0, 0);
this.rt.setScrollFactor(0, 0);

Свойства setOrigin(0, 0) и setScrollFactor(0, 0) фиксируют текстуру в верхнем левом углу экрана, и она не двигается вместе с камерой. Таким образом, она всегда покрывает весь видимый игроку холст.

В методе `update` каждый кадр происходит магия:
1.  `this.rt.clear()` — очищает текстуру от предыдущего кадра.
2.  `this.rt.fill(0x000000)` — заливает её сплошным чёрным цветом. Это создаёт эффект затемнения всего экрана.
3.  `this.rt.erase('mask', (this.player.x - 107) - cam.scrollX, (this.player.y - 107) - cam.scrollY)` — это самая важная строка. Метод `erase` не добавляет пиксели, а, наоборот, *удаляет* их из текущего содержимого `RenderTexture`, используя переданное изображение (`mask`) в качестве «ластика». Вычитание `107` центрирует маску (размером 213x213) относительно игрока. Вычитание `cam.scrollX` и `cam.scrollY` компенсирует скроллинг камеры, поскольку `RenderTexture` зафиксирована на экране. В результате в чёрном прямоугольнике «прорезается» дыра в форме маски, которая следует за игроком, создавая иллюзию освещения.
const cam = this.cameras.main;
this.rt.clear();
this.rt.fill(0x000000);
this.rt.erase('mask', (this.player.x - 107) - cam.scrollX, (this.player.y - 107) - cam.scrollY);

Управление игроком и настройка камеры

Движение игрока реализовано стандартно для платформеров на Arcade Physics: проверка нажатий клавиш и применение скорости. Камера следует за игроком в пределах карты.

this.player.body.setVelocityX(0);
if (this.cursors.left.isDown) {
    this.player.body.setVelocityX(-200);
} else if (this.cursors.right.isDown) {
    this.player.body.setVelocityX(200);
}
if ((this.cursors.space.isDown || this.cursors.up.isDown) && this.player.body.onFloor()) {
    this.player.body.setVelocityY(-300);
}
this.cameras.main.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
this.cameras.main.startFollow(this.player);
this.cursors = this.input.keyboard.createCursorKeys();

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

Мы реализовали динамический эффект освещения, используя RenderTexture как гибкий холст для постобработки. Этот метод эффективен и не нагружает GPU, как сложные шейдеры. Для экспериментов попробуйте: изменить изображение mask на текстуру с неровными краями для эффекта факела; анимировать размер или форму маски в зависимости от действий игрока; использовать несколько RenderTexture для сложных световых эффектов или применять цветное заполнение вместо чёрного для создания атмосферных фильтров.