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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    controls;

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

    create ()
    {
        this.physics.world.setBounds(0, 0, 800 * 8, 600 * 8);

        const spriteBounds = Phaser.Geom.Rectangle.Inflate(Phaser.Geom.Rectangle.Clone(this.physics.world.bounds), -100, -100);

        this.anims.create({ key: 'diamond', frames: this.anims.generateFrameNames('gems', { prefix: 'diamond_', end: 15, zeroPad: 4 }), repeat: -1 });
        this.anims.create({ key: 'prism', frames: this.anims.generateFrameNames('gems', { prefix: 'prism_', end: 6, zeroPad: 4 }), repeat: -1 });
        this.anims.create({ key: 'ruby', frames: this.anims.generateFrameNames('gems', { prefix: 'ruby_', end: 6, zeroPad: 4 }), repeat: -1 });
        this.anims.create({ key: 'square', frames: this.anims.generateFrameNames('gems', { prefix: 'square_', end: 14, zeroPad: 4 }), repeat: -1 });

        //  Create loads of random sprites

        const anims = [ 'diamond', 'prism', 'ruby', 'square' ];

        for (let i = 0; i < 1000; i++)
        {
            const pos = Phaser.Geom.Rectangle.Random(spriteBounds);

            const block = this.physics.add.sprite(pos.x, pos.y, 'gems');

            block.setVelocity(Phaser.Math.Between(200, 400), Phaser.Math.Between(200, 400));
            block.setBounce(1).setCollideWorldBounds(true);

            if (Math.random() > 0.5)
            {
                block.body.velocity.x *= -1;
            }
            else
            {
                block.body.velocity.y *= -1;
            }

            block.play(Phaser.Math.RND.pick(anims));
        }

        const cursors = this.input.keyboard.createCursorKeys();

        const controlConfig = {
            camera: this.cameras.main,
            left: cursors.left,
            right: cursors.right,
            up: cursors.up,
            down: cursors.down,
            zoomIn: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Q),
            zoomOut: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
            acceleration: 0.06,
            drag: 0.0005,
            maxSpeed: 1.0
        };

        this.controls = new Phaser.Cameras.Controls.SmoothedKeyControl(controlConfig);

        this.add.text(0, 0, 'Use Cursors to scroll camera.\nQ / E to zoom in and out', { font: '18px Courier', fill: '#00ff00' });
    }

    update (time, delta)
    {
        this.controls.update(delta);
    }
}

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

const game = new Phaser.Game(config);

Настройка мира и границ

Перед созданием объектов необходимо определить игровое пространство. В примере мир физики значительно превышает размер видимой области окна.

this.physics.world.setBounds(0, 0, 800 * 8, 600 * 8);

Метод setBounds устанавливает границы физического мира. Здесь мир в 8 раз шире и выше окна игры (800x600). Это создает большую область для перемещения объектов.

Далее мы создаем внутреннюю область для спавна спрайтов, чтобы они не появлялись у самых границ:

const spriteBounds = Phaser.Geom.Rectangle.Inflate(Phaser.Geom.Rectangle.Clone(this.physics.world.bounds), -100, -100);

Phaser.Geom.Rectangle.Clone создает копию прямоугольника границ мира. Phaser.Geom.Rectangle.Inflate "сжимает" этот прямоугольник на 100 пикселей с каждой стороны. Таким образом, spriteBounds — это область для спавна, отстоящая от краев мира.

Создание анимаций из атласа

Вместо загрузки множества отдельных изображений используется один атлас (gems) с набором кадров. Это оптимизирует загрузку ресурсов.

Анимации создаются динамически из кадров атласа:

this.anims.create({ key: 'diamond', frames: this.anims.generateFrameNames('gems', { prefix: 'diamond_', end: 15, zeroPad: 4 }), repeat: -1 });

Метод this.anims.generateFrameNames генерирует массив кадров для анимации. Он ищет в атласе gems кадры с именами, начинающимися на prefix (например, 'diamond_') и заканчивающимися номером от 0 до end. Параметр zeroPad: 4 означает, что номер кадра дополняется нулями до 4 знаков (например, 'diamond_0000', 'diamond_0001'). Параметр repeat: -1 задает бесконечное повторение анимации. Таким образом создаются четыре вида анимаций драгоценных камней.

Массовое создание физических тел

Ключевая часть примера — создание 1000 физических спрайтов в цикле.

for (let i = 0; i < 1000; i++)
{
    const pos = Phaser.Geom.Rectangle.Random(spriteBounds);
    const block = this.physics.add.sprite(pos.x, pos.y, 'gems');

Phaser.Geom.Rectangle.Random(spriteBounds) возвращает случайную точку внутри области spriteBounds. this.physics.add.sprite создает спрайт с физическим тел Arcade в этой позиции.

Каждому телу задаются свойства движения:

block.setVelocity(Phaser.Math.Between(200, 400), Phaser.Math.Between(200, 400));
block.setBounce(1).setCollideWorldBounds(true);

setVelocity устанавливает случайную начальную скорость по осям X и Y (от 200 до 400). setBounce(1) делает упругость максимальной (тело идеально отскакивает от границ). setCollideWorldBounds(true) включает столкновение с границами мира, заданными ранее.

Для разнообразия траекторий направление скорости случайным образом инвертируется:

if (Math.random() > 0.5)
{
    block.body.velocity.x *= -1;
}
else
{
    block.body.velocity.y *= -1;
}

В конце для спрайта запускается случайная анимация из списка: block.play(Phaser.Math.RND.pick(anims)).

Управление камерой с плавным движением

Так как мир огромен, нам нужен способ его осмотра. Для этого используется Phaser.Cameras.Controls.SmoothedKeyControl.

Сначала создается объект конфигурации управления:

const controlConfig = {
    camera: this.cameras.main,
    left: cursors.left,
    right: cursors.right,
    up: cursors.up,
    down: cursors.down,
    zoomIn: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Q),
    zoomOut: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
    acceleration: 0.06,
    drag: 0.0005,
    maxSpeed: 1.0
};

В конфиге указывается управляемая камера (this.cameras.main), клавиши для движения (стрелки) и зума (Q/E). Параметры acceleration (ускорение), drag (сопротивление) и maxSpeed (максимальная скорость) отвечают за плавность, инерционность движения камеры.

Экземпляр контрола создается и сохраняется: this.controls = new Phaser.Cameras.Controls.SmoothedKeyControl(controlConfig);.

Для работы контрола необходимо обновлять его в каждом кадре:

update (time, delta)
{
    this.controls.update(delta);
}

Метод update контрола использует delta (время, прошедшее с предыдущего кадра) для расчета плавного движения.

Конфигурация игры и физики

Важную роль играет настройка игры в объекте config.

physics: {
    default: 'arcade',
    arcade: {
        gravity: { y: 100 },
        debug: true
    }
},

Здесь активируется физический движок Arcade. Устанавливается гравитация по оси Y (100), из-за чего тела в примере падают вниз. Параметр debug: true включает отладочную отрисовку физических тел (показывает хитбоксы).

Обратите внимание, что type установлен в Phaser.WEBGL. WebGL-рендерер обычно производительнее Canvas при отрисовке большого количества спрайтов.

Параметр pixelArt: true включает специальный режим фильтрации текстур для пиксельной графики, что предотвращает размытие.

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

Пример демонстрирует, что Phaser Arcade Physics эффективно справляется с тысячами простых физических тел. Ключевые приемы: использование одного атласа для анимаций, задание границ мира больше видимой области и применение инерционного управления камерой для навигации. Для экспериментов попробуйте: изменить количество тел и проверьте производительность, добавить столкновения между самими телами с помощью this.physics.add.collider, заменить отскакивающие драгоценности на частицы из системы эмиттеров или реализовать разделение мира на чанки для еще большей оптимизации.