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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    xparticles;
    fire;
    lastFired = 0;
    enemies;
    bullets;
    text;
    gamepad;
    ship;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('background', 'assets/tests/space/nebula.jpg');
        this.load.atlas('space', 'assets/tests/space/space.png', 'assets/tests/space/space.json');
        this.load.atlas('explosion', 'assets/particles/explosion.png', 'assets/particles/explosion.json');
    }

    create ()
    {
        this.textures.addSpriteSheetFromAtlas('mine-sheet', { atlas: 'space', frame: 'mine', frameWidth: 64 });
        this.anims.create({ key: 'mine-anim', frames: this.anims.generateFrameNumbers('mine-sheet', { start: 0, end: 15 }), frameRate: 20, repeat: -1 });

        this.add.tileSprite(400, 300, 800, 600, 'background');
        this.add.image(200, 200, 'space', 'purple-planet').setOrigin(0);

        this.bullets = this.physics.add.group({
            classType: Bullet,
            maxSize: 30,
            runChildUpdate: true
        });

        this.ship = this.physics.add.image(400, 300, 'space', 'ship').setDepth(2);

        this.ship.setDamping(true);
        this.ship.setDrag(0.95);
        this.ship.setMaxVelocity(400);

        this.enemies = this.physics.add.group({
            classType: Enemy,
            maxSize: 60,
            runChildUpdate: true
        });

        this.text = this.add.text(10, 10, 'Press a button on the Gamepad to use', { font: '16px Courier', fill: '#00ff00' });

        this.input.gamepad.on('down', (pad, button, index) =>
        {

            if (pad.getAxisTotal() < 4)
            {
                this.text.setText('Gamepad does not have enough axis for a twin-stick demo');
            }
            else
            {
                this.text.setText('Left Stick to move, Right Stick to shoot');

                pad.setAxisThreshold(0.3);

                this.gamepad = pad;
            }

        }, this);

        this.xparticles = this.add.particles('explosion');

        /*
        xparticles.createEmitter({
            frame: [ 'smoke-puff', 'cloud', 'smoke-puff' ],
            angle: { min: 240, max: 300 },
            speed: { min: 200, max: 300 },
            quantity: 6,
            lifespan: 2000,
            alpha: { start: 1, end: 0 },
            scale: { start: 1.5, end: 0.5 },
            on: false
        });
        */

        this.xparticles.createEmitter({
            frame: 'red',
            angle: { min: 0, max: 360, steps: 32 },
            lifespan: 1000,
            speed: 400,
            quantity: 32,
            scale: { start: 0.3, end: 0 },
            on: false
        });

        this.xparticles.createEmitter({
            frame: 'muzzleflash2',
            lifespan: 200,
            scale: { start: 2, end: 0 },
            rotate: { start: 0, end: 180 },
            on: false
        });

        const particles = this.add.particles('space');

        const emitter = particles.createEmitter({
            frame: 'blue',
            speed: 200,
            lifespan: {
                onEmit: function (particle, key, t, value)
                {
                    return Phaser.Math.Percent(this.ship.body.speed, 0, 400) * 2000;
                }
            },
            alpha: {
                onEmit: function (particle, key, t, value)
                {
                    return Phaser.Math.Percent(this.ship.body.speed, 0, 400);
                }
            },
            angle: {
                onEmit: function (particle, key, t, value)
                {
                    // var v = Phaser.Math.Between(-10, 10);
                    const v = 0;
                    return (this.ship.angle - 180) + v;
                }
            },
            scale: { start: 0.6, end: 0 },
            blendMode: 'ADD'
        });

        emitter.startFollow(this.ship);



        this.physics.add.overlap(this.bullets, this.enemies, this.hitEnemy, this.checkBulletVsEnemy, this);

        for (let i = 0; i < 6; i++)
        {
            this.launchEnemy();
        }

        console.log(this.physics.world);
    }

    update (time)
    {
        if (!this.gamepad)
        {
            return;
        }

        this.text.setText([
            this.gamepad.leftStick.x,
            this.ship.body.angularVelocity
        ]);

        this.ship.setAngularVelocity(300 * this.gamepad.leftStick.x);

        if (this.gamepad.leftStick.y <= 0)
        {
            this.physics.velocityFromRotation(this.ship.rotation, Math.abs(800 * this.gamepad.leftStick.y), this.ship.body.acceleration);
        }

        this.physics.world.wrap(this.ship, 32);

        if (this.gamepad.A && time > this.lastFired)
        {
            const bullet = this.bullets.get();

            if (bullet)
            {
                bullet.fire(this.ship);

                this.lastFired = time + 100;
            }
        }
    }

    launchEnemy ()
    {
        const b = this.enemies.get();

        if (b)
        {
            b.launch();
        }
    }

    checkBulletVsEnemy (bullet, enemy)
    {
        return (bullet.active && enemy.active);
    }

    hitShip (ship, enemy)
    {
    }

    hitEnemy (bullet, enemy)
    {
        this.xparticles.emitParticleAt(enemy.x, enemy.y);

        this.cameras.main.shake(500, 0.01);

        bullet.kill();
        enemy.kill();
    }
}

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

const game = new Phaser.Game(config);

class Bullet extends Phaser.Physics.Arcade.Image
{
    constructor (scene)
    {
        super(scene, 0, 0, 'space', 'blaster');

        this.setBlendMode(1);
        this.setDepth(1);

        this.speed = 800;
        this.lifespan = 1000;

        this._temp = new Phaser.Math.Vector2();
    }

    fire (ship)
    {
        this.lifespan = 1000;

        this.setActive(true);
        this.setVisible(true);
        this.setAngle(ship.body.rotation);
        this.setPosition(ship.x, ship.y);

        this.body.reset(ship.x, ship.y);

        this.body.setSize(10, 10, true);

        const angle = Phaser.Math.DegToRad(ship.body.rotation);

        this.scene.physics.velocityFromRotation(angle, this.speed, this.body.velocity);

        this.body.velocity.x *= 2;
        this.body.velocity.y *= 2;
    }

    update (time, delta)
    {
        this.lifespan -= delta;

        if (this.lifespan <= 0)
        {
            this.kill();
        }
    }

    kill ()
    {
        this.setActive(false);
        this.setVisible(false);
        this.body.stop();
    }
}

class Enemy extends Phaser.Physics.Arcade.Sprite
{
    static spaceOuter = new Phaser.Geom.Rectangle(-200, -200, 1200, 1000);
    static spaceInner = new Phaser.Geom.Rectangle(0, 0, 800, 600);

    constructor (scene)
    {
        super(scene, 0, 0, 'mine-sheet');

        this.setDepth(1);

        this.speed = 100;
        this.checkOutOfBounds = false;
        this.target = new Phaser.Math.Vector2();
    }

    launch ()
    {
        this.play('mine-anim');

        this.checkOutOfBounds = false;

        const p = Phaser.Geom.Rectangle.RandomOutside(Enemy.spaceOuter, Enemy.spaceInner);

        Enemy.spaceInner.getRandomPoint(this.target);

        this.speed = Phaser.Math.Between(100, 400);

        this.setActive(true);
        this.setVisible(true);
        this.setPosition(p.x, p.y);

        this.body.reset(p.x, p.y);

        const angle = Phaser.Math.Angle.BetweenPoints(p, this.target);

        this.scene.physics.velocityFromRotation(angle, this.speed, this.body.velocity);
    }

    update (time, delta)
    {
        const withinGame = Enemy.spaceInner.contains(this.x, this.y);

        if (!this.checkOutOfBounds && withinGame)
        {
            this.checkOutOfBounds = true;
        }
        else if (this.checkOutOfBounds && !withinGame)
        {
            this.kill();
        }
    }

    kill ()
    {
        this.setActive(false);
        this.setVisible(false);
        this.body.stop();
        this.scene.launchEnemy();
    }
}

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

В методе preload загружаются фоновое изображение, атласы текстур для корабля, врагов, взрывов и частиц. Атласы — это упакованные изображения, которые оптимизируют производительность.

this.load.atlas('space', 'assets/tests/space/space.png', 'assets/tests/space/space.json');

В create инициализируются ключевые объекты. Создаётся анимация для врага из кадров атласа. Физические группы (this.physics.add.group) управляют пулями и врагами — это эффективнее, чем создавать каждый объект отдельно. Кораблю сразу задаются параметры физики: демпфирование (setDamping), сопротивление (setDrag) и максимальная скорость (setMaxVelocity).

this.ship.setDamping(true);
this.ship.setDrag(0.95);
this.ship.setMaxVelocity(400);

Обработка ввода с геймпада

Phaser позволяет легко работать с геймпадами. Событие 'down' на this.input.gamepad срабатывает при нажатии любой кнопки. В обработчике проверяется, достаточно ли у геймпада осей (стиков). Если осей меньше четырёх, игра сообщает об ошибке. Для подходящего геймпада устанавливается порог срабатывания осей (setAxisThreshold), чтобы игнорировать случайные микродвижения.

pad.setAxisThreshold(0.3);
this.gamepad = pad;

Теперь в update мы можем обращаться к this.gamepad. Левый стик (this.gamepad.leftStick) управляет поворотом и движением корабля. Значение .x левого стика задаёт угловую скорость (setAngularVelocity). Отрицательное значение .y (движение стика от себя) преобразуется в ускорение вперёд с помощью physics.velocityFromRotation. Корабль также оборачивается по краям мира с помощью physics.world.wrap.

this.ship.setAngularVelocity(300 * this.gamepad.leftStick.x);
if (this.gamepad.leftStick.y <= 0)
{
    this.physics.velocityFromRotation(this.ship.rotation, Math.abs(800 * this.gamepad.leftStick.y), this.ship.body.acceleration);
}

Система стрельбы и пули

Стрельба привязана к кнопке `Aна геймпаде (или её аналогу). Чтобы ограничить скорострельность, используется таймер на основе игрового времени (this.lastFired). Пуля берётся из пула группыthis.bulletsметодом.get(). Если пуля доступна (не исчерпан лимитmaxSize), вызывается её методfire`.

if (this.gamepad.A && time > this.lastFired)
{
    const bullet = this.bullets.get();
    if (bullet)
    {
        bullet.fire(this.ship);
        this.lastFired = time + 100;
    }
}

Класс Bullet расширяет Phaser.Physics.Arcade.Image. В методе fire пуля активируется, позиционируется на корабле, и ей задаётся скорость в направлении поворота корабля. У пули есть «время жизни» (lifespan), которое уменьшается в update. По истечении времени или при попадании вызывается kill, который деактивирует пулю и возвращает её в пул.

const angle = Phaser.Math.DegToRad(ship.body.rotation);
this.scene.physics.velocityFromRotation(angle, this.speed, this.body.velocity);

Враги, спавн и попадания

Враги создаются аналогично пулям — через группу this.enemies. Класс Enemy расширяет Phaser.Physics.Arcade.Sprite. В методе launch враг появляется за пределами видимой области (используется Phaser.Geom.Rectangle.RandomOutside) и получает случайную точку внутри игрового поля как цель. Скорость также выбирается случайно. В update враг отслеживает, покинул ли он игровое поле после захода в него, и если да — уничтожается, освобождая место для нового.

const p = Phaser.Geom.Rectangle.RandomOutside(Enemy.spaceOuter, Enemy.spaceInner);
Enemy.spaceInner.getRandomPoint(this.target);

Столкновения обрабатываются physics.add.overlap. Коллбэк checkBulletVsEnemy проверяет, активны ли объекты, и только тогда вызывается hitEnemy. В нём запускаются частицы взрыва, экран трясётся (cameras.main.shake), а пуля и враг уничтожаются.

this.physics.add.overlap(this.bullets, this.enemies, this.hitEnemy, this.checkBulletVsEnemy, this);

Визуальные эффекты: частицы и тряска камеры

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

lifespan: {
    onEmit: function (particle, key, t, value)
    {
        return Phaser.Math.Percent(this.ship.body.speed, 0, 400) * 2000;
    }
}

Эмиттеры для взрыва и вспышки изначально выключены (on: false). Они активируются по требованию в коде, например, при попадании пули:

this.xparticles.emitParticleAt(enemy.x, enemy.y);

Тряска камеры добавляет ощущение удара и силы выстрела или взрыва.

this.cameras.main.shake(500, 0.01);

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

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