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

Пример игры Breakout — это отличная отправная точка для изучения ключевых концепций Phaser 3. Разобрав этот код, вы поймете, как работают Arcade Physics для создания отскоков и столкновений, как эффективно управлять множеством однотипных объектов с помощью статических групп и как обрабатывать пользовательский ввод для управления игровым процессом. Этот паттерн пригодится для создания множества других казуальных игр, от арканоидов до простых платформеров.

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

Живой запуск

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

Исходный код


class Breakout extends Phaser.Scene
{
    constructor ()
    {
        super({ key: 'breakout' });

        this.bricks;
        this.paddle;
        this.ball;
    }

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.atlas('assets', 'assets/games/breakout/breakout.png', 'assets/games/breakout/breakout.json');
    }

    create ()
    {
        //  Enable world bounds, but disable the floor
        this.physics.world.setBoundsCollision(true, true, true, false);

        //  Create the bricks in a 10x6 grid
        this.bricks = this.physics.add.staticGroup({
            key: 'assets', frame: [ 'blue1', 'red1', 'green1', 'yellow1', 'silver1', 'purple1' ],
            frameQuantity: 10,
            gridAlign: { width: 10, height: 6, cellWidth: 64, cellHeight: 32, x: 112, y: 100 }
        });

        this.ball = this.physics.add.image(400, 500, 'assets', 'ball1').setCollideWorldBounds(true).setBounce(1);
        this.ball.setData('onPaddle', true);

        this.paddle = this.physics.add.image(400, 550, 'assets', 'paddle1').setImmovable();

        //  Our colliders
        this.physics.add.collider(this.ball, this.bricks, this.hitBrick, null, this);
        this.physics.add.collider(this.ball, this.paddle, this.hitPaddle, null, this);

        //  Input events
        this.input.on('pointermove', function (pointer)
        {

            //  Keep the paddle within the game
            this.paddle.x = Phaser.Math.Clamp(pointer.x, 52, 748);

            if (this.ball.getData('onPaddle'))
            {
                this.ball.x = this.paddle.x;
            }

        }, this);

        this.input.on('pointerup', function (pointer)
        {

            if (this.ball.getData('onPaddle'))
            {
                this.ball.setVelocity(-75, -300);
                this.ball.setData('onPaddle', false);
            }

        }, this);
    }

    hitBrick (ball, brick)
    {
        brick.disableBody(true, true);

        if (this.bricks.countActive() === 0)
        {
            this.resetLevel();
        }
    }

    resetBall ()
    {
        this.ball.setVelocity(0);
        this.ball.setPosition(this.paddle.x, 500);
        this.ball.setData('onPaddle', true);
    }

    resetLevel ()
    {
        this.resetBall();

        this.bricks.children.each(brick =>
        {

            brick.enableBody(false, 0, 0, true, true);

        });
    }

    hitPaddle (ball, paddle)
    {
        let diff = 0;

        if (ball.x < paddle.x)
        {
            //  Ball is on the left-hand side of the paddle
            diff = paddle.x - ball.x;
            ball.setVelocityX(-10 * diff);
        }
        else if (ball.x > paddle.x)
        {
            //  Ball is on the right-hand side of the paddle
            diff = ball.x - paddle.x;
            ball.setVelocityX(10 * diff);
        }
        else
        {
            //  Ball is perfectly in the middle
            //  Add a little random X to stop it bouncing straight up!
            ball.setVelocityX(2 + Math.random() * 8);
        }
    }

    update ()
    {
        if (this.ball.y > 600)
        {
            this.resetBall();
        }
    }
}

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

const game = new Phaser.Game(config);

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

Класс сцены — это основа любой игры в Phaser. В методе preload мы загружаем атлас текстур — единый PNG-файл со всеми спрайтами и JSON-файл с их координатами. Это эффективный способ работы с графикой.

preload ()
{
    this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
    this.load.atlas('assets', 'assets/games/breakout/breakout.png', 'assets/games/breakout/breakout.json');
}

Конструктор сцены задает её ключ и объявляет свойства для хранения основных игровых объектов. Конфигурация игры, переданная в конструктор Phaser.Game, указывает на использование WebGL, Arcade Physics и массив сцен.

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

Создание игрового мира и объектов

Метод create запускается один раз после загрузки ресурсов. Здесь мы настраиваем физический мир и создаем игровые объекты.

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

this.physics.world.setBoundsCollision(true, true, true, false);

Кирпичи создаются не по одному, а целой статической группой с помощью this.physics.add.staticGroup. Параметр gridAlign автоматически выравнивает 60 кирпичей (10x6) в сетку, используя заданные кадры из атласа по кругу. Это мощный инструмент для быстрого создания уровней.

this.bricks = this.physics.add.staticGroup({
    key: 'assets',
    frame: [ 'blue1', 'red1', 'green1', 'yellow1', 'silver1', 'purple1' ],
    frameQuantity: 10,
    gridAlign: { width: 10, height: 6, cellWidth: 64, cellHeight: 32, x: 112, y: 100 }
});

Мяч и платформа создаются как физические изображения. Мячу сразу задается отскок (setBounce(1)) и включены столкновения с границами мира. Флаг onPaddle в пользовательских данных (setData) указывает, что мяч вначале прикреплен к платформе. Платформа помечена как setImmovable(), что означает, что при столкновении с мячом она не будет реагировать на его импульс.

this.ball = this.physics.add.image(400, 500, 'assets', 'ball1').setCollideWorldBounds(true).setBounce(1);
this.ball.setData('onPaddle', true);
this.paddle = this.physics.add.image(400, 550, 'assets', 'paddle1').setImmovable();

Коллайдеры и обработка столкновений

Сердце игровой механики — это коллайдеры. Они отслеживают столкновения между объектами и вызывают callback-функции.

Коллайдер между мячом и группой кирпичей автоматически проверяет столкновение с каждым активным кирпичом в группе. При столкновении вызывается hitBrick, который "отключает" тело кирпича, удаляя его из симуляции и делая невидимым.

this.physics.add.collider(this.ball, this.bricks, this.hitBrick, null, this);
this.physics.add.collider(this.ball, this.paddle, this.hitPaddle, null, this);

Функция hitBrick использует метод disableBody. После уничтожения кирпича проверяется, остались ли еще активные кирпичи. Если нет — уровень сбрасывается.

hitBrick (ball, brick)
{
    brick.disableBody(true, true);
    if (this.bricks.countActive() === 0)
    {
        this.resetLevel();
    }
}

Функция hitPaddle реализует классическую механику отскока от платформы: в зависимости от того, в какую часть платформы попал мяч, меняется его горизонтальная скорость (setVelocityX). Это создает элемент контроля для игрока.

if (ball.x < paddle.x)
{
    diff = paddle.x - ball.x;
    ball.setVelocityX(-10 * diff);
}

Обработка ввода и управление состоянием мяча

Управление реализовано через слушатели событий указателя (мыши или касания). Событие pointermove перемещает платформу, ограничивая её координаты функцией Phaser.Math.Clamp. Если мяч еще на платформе (onPaddle), он следует за её движением.

this.input.on('pointermove', function (pointer)
{
    this.paddle.x = Phaser.Math.Clamp(pointer.x, 52, 748);
    if (this.ball.getData('onPaddle'))
    {
        this.ball.x = this.paddle.x;
    }
}, this);

Событие pointerup служит для "запуска" мяча. При первом клике, если мяч еще на платформе, ему задается начальная скорость, и флаг onPaddle сбрасывается.

this.input.on('pointerup', function ()
{
    if (this.ball.getData('onPaddle'))
    {
        this.ball.setVelocity(-75, -300);
        this.ball.setData('onPaddle', false);
    }
}, this);

В методе update проверяется условие проигрыша: если мяч улетел за нижнюю границу (координата Y > 600), он возвращается на платформу.

update ()
{
    if (this.ball.y > 600)
    {
        this.resetBall();
    }
}

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

Пример Breakout демонстрирует элегантность и мощь Phaser 3 для создания 2D-игр. Все ключевые компоненты — физика, группы объектов, управление и обработка коллизий — работают согласованно при минимальном объеме кода. Для экспериментов попробуйте: изменить параметры сетки кирпичей, добавить разные типы кирпичей с очками или бонусами, реализовать несколько уровней скорости мяча или внедрить систему жизней и счета, выводящую информацию на экран с помощью текстовых объектов Phaser.