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

В классических 2D-играх, таких как платформеры или top-down шутеры, камера часто следует за игроком. Но простое центрирование может выглядеть резко и дискомфортно для игрока. Phaser 3 предлагает мощный инструмент — мёртвую зону (deadzone) камеры. Эта статья на практическом примере покажет, как реализовать плавное и комфортное слежение за персонажем, при котором камера двигается только тогда, когда игрок приближается к границам экрана. Вы научитесь настраивать deadzone, визуализировать её для отладки и переключать режимы управления между камерой и персонажем.

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

Живой запуск

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

Исходный код


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

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        // this.load.image('bg', 'assets/pics/the-end-by-iloe-and-made.jpg');
        this.load.image('bg', 'assets/pics/uv-grid-diag.png');
        this.load.image('block', 'assets/sprites/block.png');
        this.moveCam = false;
    }

    create ()
    {
        //  Set the camera and physics bounds to be the size of 4x4 bg images
        // this.cameras.main.setBounds(0, 0, 1920 * 2, 1080 * 2);
        // this.cameras.main.setBounds(0, 0, 1024 * 4, 1024 * 4);
        // this.physics.world.setBounds(0, 0, 1920 * 2, 1080 * 2);

        //  Mash 4 images together to create our background
        // this.add.image(0, 0, 'bg').setOrigin(0);
        // this.add.image(1920, 0, 'bg').setOrigin(0).setFlipX(true);
        // this.add.image(0, 1080, 'bg').setOrigin(0).setFlipY(true);
        // this.add.image(1920, 1080, 'bg').setOrigin(0).setFlipX(true).setFlipY(true);

        this.cameras.main.setBounds(0, 0, 1024 * 4, 1024 * 4);

        for (let y = 0; y < 4; y++)
        {
            for (let x = 0; x < 4; x++)
            {
                this.add.image(1024 * x, 1024 * y, 'bg').setOrigin(0).setAlpha(0.75);
            }
        }

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

        // player = this.physics.add.image(1920, 1080, 'block');
        // player = this.physics.add.image(1024, 1024, 'block');
        // player = this.physics.add.image(10, 10, 'block');
        this.player = this.physics.add.image(1024, 1024, 'block');

        // player.setCollideWorldBounds(true);

        // this.cameras.main.setZoom(0.5);
        // this.cameras.main.setDeadzone(400, 200);

        this.cameras.main.startFollow(this.player, true);
        // this.cameras.main.startFollow(player, true, 0.1, 0.1);

        this.cameras.main.setDeadzone(400, 200);
        this.cameras.main.setZoom(0.5);

        this.input.on('pointerdown', function () {
            this.moveCam = (this.moveCam) ? false: true;
        }, this);

        if (this.cameras.main.deadzone)
        {
            const graphics = this.add.graphics().setScrollFactor(0);
            graphics.lineStyle(2, 0x00ff00, 1);
            graphics.strokeRect(200, 200, this.cameras.main.deadzone.width, this.cameras.main.deadzone.height);
        }

        this.text = this.add.text(32, 32).setScrollFactor(0).setFontSize(64).setColor('#ffffff');
    }

    update ()
    {
        const cam = this.cameras.main;

        if (cam.deadzone)
        {
            this.text.setText([
                'Cam Control: ' + this.moveCam,
                'ScrollX: ' + cam.scrollX,
                'ScrollY: ' + cam.scrollY,
                'MidX: ' + cam.midPoint.x,
                'MidY: ' + cam.midPoint.y,
                'deadzone left: ' + cam.deadzone.left,
                'deadzone right: ' + cam.deadzone.right,
                'deadzone top: ' + cam.deadzone.top,
                'deadzone bottom: ' + cam.deadzone.bottom
            ]);
        }
        else if (cam._tb)
        {
            this.text.setText([
                'Cam Control: ' + this.moveCam,
                'ScrollX: ' + cam.scrollX,
                'ScrollY: ' + cam.scrollY,
                'MidX: ' + cam.midPoint.x,
                'MidY: ' + cam.midPoint.y,
                'tb x: ' + cam._tb.x,
                'tb y: ' + cam._tb.y,
                'tb right: ' + cam._tb.right,
                'tb bottom: ' + cam._tb.bottom
            ]);
        }

        this.player.setVelocity(0);

        if (this.moveCam)
        {
            if (this.cursors.left.isDown)
            {
                cam.scrollX -= 4;
            }
            else if (this.cursors.right.isDown)
            {
                cam.scrollX += 4;
            }

            if (this.cursors.up.isDown)
            {
                cam.scrollY -= 4;
            }
            else if (this.cursors.down.isDown)
            {
                cam.scrollY += 4;
            }
        }
        else
        {
            if (this.cursors.left.isDown)
            {
                this.player.setVelocityX(-800);
            }
            else if (this.cursors.right.isDown)
            {
                this.player.setVelocityX(800);
            }

            if (this.cursors.up.isDown)
            {
                this.player.setVelocityY(-800);
            }
            else if (this.cursors.down.isDown)
            {
                this.player.setVelocityY(800);
            }
        }
    }
}

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

const game = new Phaser.Game(config);

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

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

this.cameras.main.setBounds(0, 0, 1024 * 4, 1024 * 4);

for (let y = 0; y < 4; y++) {
    for (let x = 0; x < 4; x++) {
        this.add.image(1024 * x, 1024 * y, 'bg').setOrigin(0).setAlpha(0.75);
    }
}

Метод setBounds устанавливает границы, внутри которых может перемещаться камера. Далее в двойном цикле создаётся сетка 4x4 из фоновых изображений, заполняющая эти границы. Игрок (this.player) создаётся как физический спрайт в центре мира.

this.player = this.physics.add.image(1024, 1024, 'block');

Настройка следящей камеры и мёртвой зоны

Сердце примера — настройка камеры. Сначала мы запускаем слежение за спрайтом игрока с помощью startFollow. Аргумент true включает линейную интерполяцию (LERP), что делает движение камеры более плавным.

this.cameras.main.startFollow(this.player, true);

Затем определяется мёртвая зона с помощью setDeadzone. Это прямоугольная область в центре видимой части камеры (вьюпорта). Пока цель слежения (игрок) находится внутри этой зоны, камера остаётся неподвижной. Как только игрок пересекает её границу, камера начинает двигаться, чтобы снова поместить его внутрь deadzone.

this.cameras.main.setDeadzone(400, 200);
this.cameras.main.setZoom(0.5);

В данном случае создаётся зона шириной 400 и высотой 200 пикселей. Метод setZoom(0.5) отдаляет камеру, чтобы лучше видеть и мир, и зону. Для наглядности deadzone отрисовывается на экране зелёным прямоугольником.

if (this.cameras.main.deadzone) {
    const graphics = this.add.graphics().setScrollFactor(0);
    graphics.lineStyle(2, 0x00ff00, 1);
    graphics.strokeRect(200, 200, this.cameras.main.deadzone.width, this.cameras.main.deadzone.height);
}

Двойной режим управления и отладка

Пример реализует два режима управления, переключаемые кликом мыши (pointerdown). В режиме this.moveCam = false (по умолчанию) стрелками клавиатуры управляется скорость игрока, а камера автоматически следует за ним в рамках deadzone.

if (this.cursors.left.isDown) {
    this.player.setVelocityX(-800);
}

Когда this.moveCam = true, стрелками напрямую управляется прокрутка (scrollX, scrollY) камеры, а игрок остаётся на месте. Это полезно для отладки и понимания принципа работы.

if (this.cursors.left.isDown) {
    cam.scrollX -= 4;
}

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

this.text.setText([
    'Cam Control: ' + this.moveCam,
    'ScrollX: ' + cam.scrollX,
    'deadzone left: ' + cam.deadzone.left,
]);

Как работает мёртвая зона на практике

Когда игрок движется в режиме слежения, камера не дрожит от каждого его мелкого перемещения. Она остаётся статичной, пока позиция игрока относительно центра камеры находится в пределах прямоугольника deadzone. Это создаёт более комфортный визуальный опыт.

Представьте, что экран — это окно, а deadzone — безопасная область внутри него. Игрок может свободно перемещаться в этой безопасной области, и вид за окном не меняется. Как только он заходит слишком близко к краю окна (границе deadzone), всё окно (камера) плавно сдвигается, возвращая его в безопасную зону.

Эту логику обрабатывает внутренний механизм камеры Phaser после вызова startFollow. Нам лишь нужно правильно задать размеры зоны в setDeadzone. Широкая зона (например, 400x300) даст игроку больше свободы передвижения без движения камеры, а узкая (например, 100x100) заставит камеру реагировать на малейшее смещение, что может быть полезно для динамичных сцен.

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

Использование мёртвой зоны — ключ к созданию профессиональной и комфортной для игрока камеры в 2D-играх на Phaser 3. Она избавляет от резких дерганий и позволяет игроку сфокурироваться на действии. Для экспериментов попробуйте: изменить размеры deadzone на лету в ответ на события игры; использовать вертикальную deadzone для платформеров, где важнее контроль по оси Y; комбинировать deadzone с дополнительными эффектами камеры, такими как setFollowOffset или setLerp для изменения плавности.