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

В классических шутерах сверху камера обычно жестко закреплена на персонаже, что ограничивает обзор. В этой статье мы разберем пример из Phaser, где камера плавно следует за средней точкой между игроком и прицелом. Этот прием, популярный в играх вроде "Hotline Miami", дает игроку лучший контроль и ситуационную осведомленность. Вы научитесь работать с привязкой указателя мыши, физикой Arcade и динамическим позиционированием камеры.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    time = 0;
    lastFired = 0;
    bullets;
    moveKeys;
    reticle;
    player;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        // Load in images and sprites
        this.load.spritesheet(
            'player_handgun',
            'assets/sprites/player_handgun.png',
            { frameWidth: 66, frameHeight: 60 }
        ); // Made by tokkatrain: https://tokkatrain.itch.io/top-down-basic-set
        this.load.image('target', 'assets/demoscene/ball.png');
        this.load.image('background', 'assets/skies/underwater1.png');
    }

    create ()
    {
        // Create world bounds
        this.physics.world.setBounds(0, 0, 1600, 1200);

        // Add background, player, and reticle sprites
        const background = this.add.image(800, 600, 'background');
        this.player = this.physics.add.sprite(800, 600, 'player_handgun');
        this.reticle = this.physics.add.sprite(800, 700, 'target');

        // Set image/sprite properties
        background.setOrigin(0.5, 0.5).setDisplaySize(1600, 1200);
        this.player
            .setOrigin(0.5, 0.5)
            .setDisplaySize(132, 120)
            .setCollideWorldBounds(true)
            .setDrag(500, 500);
        this.reticle
            .setOrigin(0.5, 0.5)
            .setDisplaySize(25, 25)
            .setCollideWorldBounds(true);

        // Set camera zoom
        this.cameras.main.zoom = 0.5;

        // Creates object for input with WASD kets
        this.moveKeys = this.input.keyboard.addKeys({
            up: Phaser.Input.Keyboard.KeyCodes.W,
            down: Phaser.Input.Keyboard.KeyCodes.S,
            left: Phaser.Input.Keyboard.KeyCodes.A,
            right: Phaser.Input.Keyboard.KeyCodes.D
        });

        // Enables movement of player with WASD keys
        this.input.keyboard.on('keydown_W', (event) => {
            this.player.setAccelerationY(-800);
        });
        this.input.keyboard.on('keydown_S', (event) => {
            this.player.setAccelerationY(800);
        });
        this.input.keyboard.on('keydown_A', (event) => {
            this.player.setAccelerationX(-800);
        });
        this.input.keyboard.on('keydown_D', (event) => {
            this.player.setAccelerationX(800);
        });

        // Stops player acceleration on uppress of WASD keys
        this.input.keyboard.on('keyup_W', (event) => {
            if (this.moveKeys['down'].isUp) { this.player.setAccelerationY(0); }
        });
        this.input.keyboard.on('keyup_S', (event) => {
            if (this.moveKeys['up'].isUp) { this.player.setAccelerationY(0); }
        });
        this.input.keyboard.on('keyup_A', (event) => {
            if (this.moveKeys['right'].isUp) { this.player.setAccelerationX(0); }
        });
        this.input.keyboard.on('keyup_D', (event) => {
            if (this.moveKeys['left'].isUp) { this.player.setAccelerationX(0); }
        });

        // Locks pointer on mousedown
        game.canvas.addEventListener('mousedown', () => {
            game.input.mouse.requestPointerLock();
        });

        // Exit pointer lock when Q or escape (by default) is pressed.
        this.input.keyboard.on(
            'keydown_Q',
            (event) =>
            {
                if (game.input.mouse.locked) { game.input.mouse.releasePointerLock(); }
            },
            0,
        );

        // Move reticle upon locked pointer move
        this.input.on(
            'pointermove',
            function (pointer)
            {
                if (this.input.mouse.locked)
                {
                    // Move reticle with mouse
                    this.reticle.x += pointer.movementX;
                    this.reticle.y += pointer.movementY;

                    // Only works when camera follows player
                    const distX = this.reticle.x - this.player.x;
                    const distY = this.reticle.y - this.player.y;

                    // Ensures reticle cannot be moved offscreen
                    if (distX > 800) { this.reticle.x = this.player.x + 800; }
                    else if (distX < -800) { this.reticle.x = this.player.x - 800; }

                    if (distY > 600) { this.reticle.y = this.player.y + 600; }
                    else if (distY < -600) { this.reticle.y = this.player.y - 600; }
                }
            },
            this
        );
    }

    update (time, delta)
    {
        // Rotates player to face towards reticle
        this.player.rotation = Phaser.Math.Angle.Between(
            this.player.x,
            this.player.y,
            this.reticle.x,
            this.reticle.y
        );

        // Camera position is average between reticle and player positions
        const avgX = (this.player.x + this.reticle.x) / 2 - 400;
        const avgY = (this.player.y + this.reticle.y) / 2 - 300;
        this.cameras.main.scrollX = avgX;
        this.cameras.main.scrollY = avgY;

        // Make reticle move with player
        this.reticle.body.velocity.x = this.player.body.velocity.x;
        this.reticle.body.velocity.y = this.player.body.velocity.y;

        // Constrain velocity of player
        this.constrainVelocity(this.player, 500);

        // Constrain position of reticle
        this.constrainReticle(this.reticle, 550);
    }

    constrainVelocity (sprite, maxVelocity)
    {
        if (!sprite || !sprite.body) { return; }

        let angle, currVelocitySqr, vx, vy;
        vx = sprite.body.velocity.x;
        vy = sprite.body.velocity.y;
        currVelocitySqr = vx * vx + vy * vy;

        if (currVelocitySqr > maxVelocity * maxVelocity)
        {
            angle = Math.atan2(vy, vx);
            vx = Math.cos(angle) * maxVelocity;
            vy = Math.sin(angle) * maxVelocity;
            sprite.body.velocity.x = vx;
            sprite.body.velocity.y = vy;
        }
    }

    constrainReticle (reticle, radius)
    {
        const distX = reticle.x - this.player.x; // X distance between player & reticle
        const distY = reticle.y - this.player.y; // Y distance between player & reticle

        // Ensures reticle cannot be moved offscreen
        if (distX > 800) { reticle.x = this.player.x + 800; }
        else if (distX < -800) { reticle.x = this.player.x - 800; }

        if (distY > 600) { reticle.y = this.player.y + 600; }
        else if (distY < -600) { reticle.y = this.player.y - 600; }

        // Ensures reticle cannot be moved further than dist(radius) from player
        const distBetween = Phaser.Math.Distance.Between(
            this.player.x,
            this.player.y,
            reticle.x,
            reticle.y
        );
        if (distBetween > radius)
        {
            // Place reticle on perimeter of circle on line intersecting player & reticle
            const scale = distBetween / radius;

            reticle.x = this.player.x + (reticle.x - this.player.x) / scale;
            reticle.y = this.player.y + (reticle.y - this.player.y) / scale;
        }
    }
}

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

const game = new Phaser.Game(config);

Настройка мира и объектов

Сцена начинается с создания игрового мира и ключевых спрайтов. Важно установить границы мира, чтобы объекты не могли уйти за его пределы.

this.physics.world.setBounds(0, 0, 1600, 1200);

Затем создаются фоновое изображение, спрайт игрока и спрайт прицела. Обратите внимание, что игрок и прицел добавляются через this.physics.add.sprite, что автоматически наделяет их физическими телами Arcade.

this.player = this.physics.add.sprite(800, 600, 'player_handgun');
this.reticle = this.physics.add.sprite(800, 700, 'target');

Для игрока сразу настраивается сопротивление (setDrag), чтобы движение было инерционным и не скользящим. Камера уменьшает масштаб (zoom = 0.5), чтобы в поле зрения попадало больше пространства.

Управление: клавиатура и захват мыши

Управление разделено на две части: движение на WASD и прицеливание мышью. Для клавиатуры используется объект moveKeys и отдельные обработчики событий keydown и keyup.

this.input.keyboard.on('keydown_W', (event) => {
    this.player.setAccelerationY(-800);
});

Особенность обработки keyup в том, что ускорение обнуляется только если противоположная клавиша не нажата. Это предотвращает резкую остановку при переключении, например, с `WнаS`.

Захват указателя мыши (Pointer Lock) — ключевая технология для игр, где курсор не должен выходить за пределы окна браузера. Он активируется кликом по canvas.

game.canvas.addEventListener('mousedown', () => {
    game.input.mouse.requestPointerLock();
});

При движении захваченного курсора (pointermove) мы обновляем позицию прицела, используя pointer.movementX/Y. Это дает плавное и прямое управление.

Логика прицела: ограничение радиуса

Прицел не должен улетать слишком далеко от игрока. В методе constrainReticle реализовано два уровня ограничений.

Во-первых, прицел не может уйти за видимые границы экрана относительно игрока (на 800 пикселей по X и 600 по Y). Эти значения связаны с размерами окна игры и зумом камеры.

if (distX > 800) { reticle.x = this.player.x + 800; }

Во-вторых, прицел ограничивается максимальным радиусом (550 пикселей) от игрока. Если дистанция превышена, прицел "притягивается" к границе этой воображаемой окружности.

const scale = distBetween / radius;
reticle.x = this.player.x + (reticle.x - this.player.x) / scale;

Также в update прицелу передается скорость игрока, чтобы они двигались как единое целое: this.reticle.body.velocity.x = this.player.body.velocity.x.

Вращение игрока и умная камера

Каждый кадр игрок поворачивается лицом к прицелу. Для этого используется Phaser.Math.Angle.Between, которая возвращает угол между двумя точками.

this.player.rotation = Phaser.Math.Angle.Between(
    this.player.x,
    this.player.y,
    this.reticle.x,
    this.reticle.y
);

Сердце примера — позиционирование камеры. Она следует не за игроком, а за средней точкой между игроком и прицелом.

const avgX = (this.player.x + this.reticle.x) / 2 - 400;
const avgY = (this.player.y + this.reticle.y) / 2 - 300;
this.cameras.main.scrollX = avgX;
this.cameras.main.scrollY = avgY;

Вычитание 400 и 300 пикселей — это смещение, необходимое для центровки средней точки в окне игры (размером 800x600). Камера предвосхищает направление стрельбы, давая игроку обзор в нужную сторону.

Контроль скорости и физика

Метод constrainVelocity ограничивает максимальную скорость игрока, даже если ускорение прикладывается долго. Это стандартный прием для создания "капсульной" физики.

if (currVelocitySqr > maxVelocity * maxVelocity) {
    angle = Math.atan2(vy, vx);
    vx = Math.cos(angle) * maxVelocity;
    vy = Math.sin(angle) * maxVelocity;
    sprite.body.velocity.x = vx;
    sprite.body.velocity.y = vy;
}

В конфигурации физики Arcade важно отметить, что гравитация отключена (gravity: { y: 0 }), так как это игра в плоскости. Отладка (debug: true) позволяет видеть хитбоксы тел, что полезно при разработке.

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

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