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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    time = 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, this);

        // Move reticle upon locked pointer move
        this.input.on('pointermove', function (pointer)
        {
            if (this.input.mouse.locked)
            {
                this.reticle.x += pointer.movementX;
                this.reticle.y += pointer.movementY;
            }
        }, 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 follows player ( can be set in create )
        this.cameras.main.startFollow(this.player);

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

    }

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

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

Настройка сцены и загрузка ассетов

В методе preload мы загружаем спрайт игрока в виде спрайтшита и изображения для прицела и фона. Обратите внимание на установку базового URL для удобства.

this.load.spritesheet('player_handgun', 'assets/sprites/player_handgun.png',
    { frameWidth: 66, frameHeight: 60 }
);
this.load.image('target', 'assets/demoscene/ball.png');
this.load.image('background', 'assets/skies/underwater1.png');

В create задаются границы физического мира, создаются спрайты и настраиваются их свойства. Камера уменьшает масштаб (zoom), чтобы в поле зрения попадало больше пространства.

this.physics.world.setBounds(0, 0, 1600, 1200);
this.player = this.physics.add.sprite(800, 600, 'player_handgun');
this.reticle = this.physics.add.sprite(800, 700, 'target');
this.player.setCollideWorldBounds(true).setDrag(500, 500);
this.cameras.main.zoom = 0.5;

Управление движением игрока

Для управления используется объект moveKeys, созданный через this.input.keyboard.addKeys. Ускорение применяется при нажатии клавиш и сбрасывается при отпускании. Условия в обработчиках keyup предотвращают резкую остановку, если нажаты две противоположные клавиши.

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

this.input.keyboard.on('keydown_W', event => {
    this.player.setAccelerationY(-800);
});
this.input.keyboard.on('keyup_W', event => {
    if (this.moveKeys['down'].isUp) { this.player.setAccelerationY(0); }
});

Блокировка мыши и управление прицелом

Чтобы прицел не уходил за пределы окна браузера, используется Pointer Lock API. Блокировка активируется кликом по canvas, а снимается клавишей Q.

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

this.input.keyboard.on('keydown_Q', event => {
    if (game.input.mouse.locked) { game.input.mouse.releasePointerLock(); }
}, 0, this);

При перемещении заблокированного указателя координаты прицела меняются на величину pointer.movementX и pointer.movementY.

this.input.on('pointermove', function (pointer)
{
    if (this.input.mouse.locked)
    {
        this.reticle.x += pointer.movementX;
        this.reticle.y += pointer.movementY;
    }
}, this);

Обновление состояния в update

В update игрок поворачивается в сторону прицела с помощью Phaser.Math.Angle.Between. Камера начинает следовать за игроком, а прицел наследует его скорость.

this.player.rotation = Phaser.Math.Angle.Between(this.player.x, this.player.y, this.reticle.x, this.reticle.y);
this.cameras.main.startFollow(this.player);
this.reticle.body.velocity.x = this.player.body.velocity.x;
this.reticle.body.velocity.y = this.player.body.velocity.y;

Затем вызываются вспомогательные методы для ограничения скорости игрока и позиции прицела относительно игрока.

Ограничение скорости и позиции прицела

Метод 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;
}

constrainReticle удерживает прицел в радиусе 800 пикселей по X и 600 по Y от игрока, не давая ему уйти слишком далеко.

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

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

Вы реализовали основу для top-down шутера с физикой Arcade, фокусировкой камеры на игроке и управлением прицелом мышью. Для экспериментов попробуйте добавить стрельбу по клику, врагов, которые следуют за игроком, или изменить логику ограничения прицела на динамическую, зависящую от скорости движения.