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

В этом примере из официальных демонстраций Phaser показана реализация классической аркадной игры в рамках одной сцены. Код демонстрирует ключевые концепции для создания динамичных игр: управление группами объектов, обработка столкновений, управление камерами и таймеры. Разбор этого примера поможет вам понять, как структурировать игровую логику и эффективно использовать физический движок Phaser для прототипирования жанра shoot 'em up.

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

Живой запуск

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

Исходный код


class Invaders extends Phaser.Scene {

    constructor (handle, parent)
    {
        super(handle);

        this.parent = parent;

        this.left;
        this.right;

        this.ship;

        this.invaders;
        this.mothership;
        this.bullet;

        this.topLeft;
        this.bottomRight;

        this.bulletTimer;
        this.mothershipTimer;

        this.isGameOver = false;

        this.invadersBounds = { x: 12, y: 62, right: 152 };
    }

    create (config)
    {
        this.left = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT);
        this.right = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT);

        this.physics.world.setBounds(4, 22, 400, 300);

        this.cameras.main.setViewport(this.parent.x, this.parent.y, Invaders.WIDTH, Invaders.HEIGHT);
        this.cameras.main.setBackgroundColor('#000');

        this.createInvaders();

        this.bullet = this.physics.add.image(200, 290, 'invaders.bullet2');

        this.mothership = this.physics.add.image(500, 40, 'invaders.mothership');

        this.ship = this.physics.add.image(200, 312, 'invaders.ship');

        var bg = this.add.image(0, 0, 'invadersWindow').setOrigin(0);

        this.ship.setCollideWorldBounds(true);

        this.physics.add.overlap(this.bullet, this.invaders, this.bulletHit, null, this);
        this.physics.add.overlap(this.bullet, this.mothership, this.bulletHitMothership, null, this);

        this.launchBullet();

        this.mothershipTimer = this.time.addEvent({ delay: 10000, callback: this.launchMothership, callbackScope: this, repeat: -1 });

        this.invaders.setVelocityX(50);
    }

    launchMothership ()
    {
        this.mothership.setVelocityX(-100);
    }

    bulletHit (bullet, invader)
    {
        this.launchBullet();

        invader.body.enable = false;

        this.invaders.killAndHide(invader);

        this.refreshOutliers();
    }

    bulletHitMothership (bullet, mothership)
    {
        this.launchBullet();

        this.mothership.body.reset(500, 40);
    }

    refreshOutliers ()
    {
        const list = this.invaders.getChildren();

        let first = this.invaders.getFirst(true);
        let last = this.invaders.getLast(true);

        for (let i = 0; i < list.length; i++)
        {
            const vader = list[i];

            if (vader.active)
            {
                if (vader.x < first.x)
                {
                    first = vader;
                }
                else if (vader.x > last.x)
                {
                    last = vader;
                }
            }
        }

        if (this.topLeft === null && this.bottomRight === null)
        {
            this.gameOver();
        }

        this.topLeft = first;
        this.bottomRight = last;
    }

    launchBullet ()
    {
        this.bullet.body.reset(this.ship.x, this.ship.y);

        this.bullet.body.velocity.y = -400;
    }

    createInvaders ()
    {
        this.invaders = this.physics.add.group();

        let x = this.invadersBounds.x;
        let y = this.invadersBounds.y;

        for (let i = 0; i < 10; i++)
        {
            this.invaders.create(x, y, 'invaders.invader1').setTint(0xff0000).play('invader1');

            x += 26;
        }

        x = this.invadersBounds.x;
        y += 28

        for (let i = 0; i < 16; i++)
        {
            this.invaders.create(x, y, 'invaders.invader2').setTint(0x00ff00).play('invader2');

            x += 33;

            if (i === 7)
            {
                x = this.invadersBounds.x;
                y += 28;
            }
        }

        x = this.invadersBounds.x;
        y += 28

        for (let i = 0; i < 14; i++)
        {
            this.invaders.create(x, y, 'invaders.invader3').setTint(0x00ffff).play('invader3');

            x += 38;

            if (i === 6)
            {
                x = this.invadersBounds.x;
                y += 28;
            }
        }

        //  We can use these markers to work out where the whole Group is and how wide it is
        this.topLeft = this.invaders.getFirst(true);
        this.bottomRight = this.invaders.getLast(true);
    }

    refresh ()
    {
        this.cameras.main.setPosition(this.parent.x, this.parent.y);

        this.scene.bringToTop();
    }

    gameOver ()
    {
        this.invaders.setVelocityX(0);

        this.ship.setVisible(false);

        this.bullet.setVisible(false);

        this.isGameOver = true;
    }

    update ()
    {
        if (this.isGameOver || (this.bottomRight === null && this.topLeft === null))
        {
            return;
        }

        if (this.left.isDown)
        {
            this.ship.body.velocity.x = -400;
        }
        else if (this.right.isDown)
        {
            this.ship.body.velocity.x = 400;
        }
        else
        {
            this.ship.body.velocity.x = 0;
        }

        //  Bullet bounds
        if (this.bullet.y < -32)
        {
            this.launchBullet();
        }

        //  Invaders bounds

        let moveDown = false;

        if (this.bottomRight.body.velocity.x > 0 && this.bottomRight.x >= 390)
        {
            this.invaders.setVelocityX(-50);
            moveDown = true;
        }
        else if (this.topLeft.body.velocity.x < 0 && this.topLeft.x <= 12)
        {
            this.invaders.setVelocityX(50);
            moveDown = true;
        }

        if (moveDown)
        {
            const list = this.invaders.getChildren();
            let lowest = 0;

            for (let i = 0; i < list.length; i++)
            {
                const vader = list[i];

                vader.body.y += 4;

                if (vader.active && vader.body.y > lowest)
                {
                    lowest = vader.body.y;
                }
            }

            if (lowest > 240)
            {
                this.gameOver();
            }
        }
    }

}

Invaders.WIDTH = 408;
Invaders.HEIGHT = 326;

Инициализация сцены и настройка ввода

Класс Invaders наследуется от Phaser.Scene. В конструкторе инициализируются свойства для хранения ссылок на игровые объекты и состояния.

В методе create происходит первичная настройка сцены. Сначала создаются объекты для управления с клавиатуры, используя Phaser.Input.Keyboard.KeyCodes. Затем задаются границы физического мира и настраивается вид камеры с заданным цветом фона. Важно отметить, что камера позиционируется относительно переданных извне координат (this.parent.x, this.parent.y), что позволяет встраивать эту сцену как часть более крупного проекта.

this.left = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT);
this.right = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT);
this.physics.world.setBounds(4, 22, 400, 300);
this.cameras.main.setViewport(this.parent.x, this.parent.y, Invaders.WIDTH, Invaders.HEIGHT);
this.cameras.main.setBackgroundColor('#000');

Создание и управление группами объектов (Groups)

Одна из центральных механик — управление флотом пришельцев как единым целым. Для этого используется Physics Group. Метод createInvaders создает группу this.invaders и заполняет ее спрайтами в три ряда, используя циклы и заранее заданные координаты из this.invadersBounds. Каждому пришельцу задается текстура, оттенок (setTint) и запускается анимация (play).

Группа позволяет управлять всеми объектами одновременно, например, задавать общую скорость. Также сохраняются ссылки на крайние объекты (this.topLeft, this.bottomRight) для последующей проверки столкновений с границами экрана.

this.invaders = this.physics.add.group();
// ... создание спрайтов в цикле
this.invaders.create(x, y, 'invaders.invader1').setTint(0xff0000).play('invader1');
// ...
this.topLeft = this.invaders.getFirst(true);
this.bottomRight = this.invaders.getLast(true);

Обработка столкновений и игровая логика

Физика столкновений настраивается с помощью метода this.physics.add.overlap. Он регистрирует обработчики для двух пар объектов: пули и пришельцев, а также пули и материнского корабля. При срабатывании коллбэка bulletHit пуля перезапускается, а пришелец деактивируется через body.enable = false и скрывается из группы методом killAndHide. После этого вызывается refreshOutliers, который пересчитывает крайние активные объекты в группе.

Для автоматического запуска пуль и появления материнского корабля используются таймеры Phaser.

this.physics.add.overlap(this.bullet, this.invaders, this.bulletHit, null, this);
this.physics.add.overlap(this.bullet, this.mothership, this.bulletHitMothership, null, this);
this.mothershipTimer = this.time.addEvent({ delay: 10000, callback: this.launchMothership, callbackScope: this, repeat: -1 });
bulletHit (bullet, invader) {
    this.launchBullet();
    invader.body.enable = false;
    this.invaders.killAndHide(invader);
    this.refreshOutliers();
}

Игровой цикл и движение флота

Основная игровая логика обновления находится в методе update. Здесь обрабатывается ввод для движения корабля игрока, проверяются границы для пули и реализуется знаменитое "зигзагообразное" движение флота пришельцев.

Движение флота управляется через общую скорость группы this.invaders.setVelocityX. Когда крайний правый или левый пришелец (this.bottomRight или this.topLeft) достигает границы игровой зоны, скорость инвертируется, и весь флот опускается вниз на 4 пикселя. Если флот опускается слишком низко (lowest > 240), игра завершается.

if (this.bottomRight.body.velocity.x > 0 && this.bottomRight.x >= 390) {
    this.invaders.setVelocityX(-50);
    moveDown = true;
}
// ...
if (moveDown) {
    const list = this.invaders.getChildren();
    for (let i = 0; i < list.length; i++) {
        const vader = list[i];
        vader.body.y += 4;
    }
}

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

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