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

При разработке игр часто возникает необходимость обрабатывать взаимодействие с сотнями или тысячами объектов. Нативный подход с отдельными обработчиками событий для каждого спрайта быстро приводит к падению производительности. В этой статье мы разберем пример из официальной документации Phaser, который демонстрирует, как эффективно и быстро обрабатывать наведение и клики по 10 000 игровым объектам. Мы рассмотрим ключевые техники: использование единой области взаимодействия (hitArea), группового создания объектов и камерного скроллинга.

Версия 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.spritesheet('bobs', 'assets/sprites/bobs-by-cleathley.png', { frameWidth: 32, frameHeight: 32 });
    }

    create ()
    {
        //  Create a little 32x32 texture to use to show where the mouse is
        const graphics = this.make.graphics({ x: 0, y: 0, add: false, fillStyle: { color: 0xff00ff, alpha: 1 } });

        graphics.fillRect(0, 0, 32, 32);

        graphics.generateTexture('block', 32, 32);

        const highlighted = this.add.image(16, 16, 'block');

        //  All the Images can share the same Shape, no need for a unique instance per one, a reference is fine
        const hitArea = new Phaser.Geom.Rectangle(0, 0, 32, 32);
        const hitAreaCallback = Phaser.Geom.Rectangle.Contains;

        //  Create 10,000 Image Game Objects aligned in a grid
        //  Change this to 2000 on MS Edge as it can't seem to cope with 10k at the moment
        const group = this.make.group({
            classType: Phaser.GameObjects.Image,
            key: 'bobs',
            frame: Phaser.Utils.Array.NumberArray(0, 399),
            randomFrame: true,
            repeat: 24,
            max: 10000,
            hitArea: hitArea,
            hitAreaCallback: hitAreaCallback,
            gridAlign: {
                width: 100,
                cellWidth: 32,
                cellHeight: 32
            }
        });

        //  Camera controls
        const cursors = this.input.keyboard.createCursorKeys();

        const controlConfig = {
            camera: this.cameras.main,
            left: cursors.left,
            right: cursors.right,
            up: cursors.up,
            down: cursors.down,
            acceleration: 0.04,
            drag: 0.0005,
            maxSpeed: 0.7
        };

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

        this.input.on('pointerover', (pointer, gameObjects) =>
        {

            highlighted.setPosition(gameObjects[0].x, gameObjects[0].y);

        });

        this.input.on('gameobjectdown', (pointer, gameObject) =>
        {

            gameObject.visible = false;

        });
    }

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

const config = {
    type: Phaser.WEBGL,
    parent: 'phaser-example',
    width: 800,
    height: 600,
    scene: Example
};

const game = new Phaser.Game(config);

Создание массы объектов через группу (Group)

Вместо создания и позиционирования каждого спрайта вручную, код использует фабрику this.make.group. Это мощный метод Phaser для массового создания однотипных игровых объектов с заданными параметрами.

const group = this.make.group({
    classType: Phaser.GameObjects.Image,
    key: 'bobs',
    frame: Phaser.Utils.Array.NumberArray(0, 399),
    randomFrame: true,
    repeat: 24,
    max: 10000,
    hitArea: hitArea,
    hitAreaCallback: hitAreaCallback,
    gridAlign: {
        width: 100,
        cellWidth: 32,
        cellHeight: 32
    }
});

Здесь происходит следующее: - classType: Задает тип создаваемых объектов — Phaser.GameObjects.Image. - key и frame: Указывает на ключ спрайтшита, загруженного в preload(). Phaser.Utils.Array.NumberArray(0, 399) создает массив из 400 индексов кадров, а randomFrame: true случайным образом назначает их объектам. - repeat и max: Параметры управляют количеством. Объекты создаются до тех пор, пока их не станет max (10000). - gridAlign: Автоматически выравнивает все созданные объекты в сетку шириной 100 колонок (width: 100) с размером ячейки 32x32 пикселя. Это избавляет от необходимости вручную рассчитывать координаты X и Y для каждого спрайта.

Единая область взаимодействия (Hit Area)

Ключ к производительности при работе с тысячами объектов — использование единой геометрической формы для проверки пересечения с курсором, вместо индивидуальных расчетов для каждого спрайта.

const hitArea = new Phaser.Geom.Rectangle(0, 0, 32, 32);
const hitAreaCallback = Phaser.Geom.Rectangle.Contains;

Эти две строки настраивают общую логику взаимодействия: 1. hitArea: Создает прямоугольник размером 32x32 пикселя. Этот прямоугольник будет использоваться как виртуальная «горячая зона» для каждого из 10 000 изображений. 2. hitAreaCallback: Это функция (Phaser.Geom.Rectangle.Contains), которая будет вызываться для проверки, находится ли точка (курсор) внутри этого прямоугольника. Она передается в конфиг группы, и Phaser применяет ее ко всем объектам.

Таким образом, система ввода проверяет не 10 000 сложных форм, а позицию курсора относительно простого прямоугольника, что в разы эффективнее.

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

Phaser предоставляет глобальные события ввода, которые срабатывают при взаимодействии с любым игровым объектом, имеющим hitArea. В коде подписаны два таких события.

this.input.on('pointerover', (pointer, gameObjects) => {
    highlighted.setPosition(gameObjects[0].x, gameObjects[0].y);
});

Событие 'pointerover' срабатывает, когда курсор попадает в hitArea объекта. Колбэк получает массив gameObjects — все объекты под курсором (обычно это один объект). Код берет первый (gameObjects[0]) и позиционирует спрайт-маркер (highlighted) на его координаты, визуализируя наведение.

this.input.on('gameobjectdown', (pointer, gameObject) => {
    gameObject.visible = false;
});

Событие 'gameobjectdown' срабатывает при нажатии кнопки мыши на объекте. В колбэке конкретный gameObject, по которому кликнули, просто скрывается (visible = false). Обратите внимание, что здесь в колбэк передается одиночный объект, а не массив.

Плавное управление камерой для навигации

Чтобы можно было осмотреть все 10 000 объектов, реализовано управление камерой с клавиатуры с помощью SmoothedKeyControl. Этот контроллер обеспечивает плавное, инерционное движение.

const controlConfig = {
    camera: this.cameras.main,
    left: cursors.left,
    right: cursors.right,
    up: cursors.up,
    down: cursors.down,
    acceleration: 0.04,
    drag: 0.0005,
    maxSpeed: 0.7
};
this.controls = new Phaser.Cameras.Controls.SmoothedKeyControl(controlConfig);

Конфиг связывает основные клавиши-стрелки с управлением главной камерой. Параметры acceleration (ускорение) и drag (сопротивление) создают эффект плавного разгона и замедления. maxSpeed ограничивает максимальную скорость перемещения.

Обновление состояния контроллера происходит каждый кадр в методе update:

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

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

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

Этот пример наглядно демонстрирует, как Phaser позволяет работать с огромным количеством интерактивных объектов, оставаясь в рамках высокой производительности. Главные выводы: используйте Group для массового создания, назначайте общую hitArea для множества объектов и обрабатывайте взаимодействие через глобальные события ввода. Для экспериментов попробуйте: изменить форму hitArea на круг (Phaser.Geom.Circle), реализовать более сложную логику при клике (например, менять не visible, а tint), или увеличить maxSpeed камеры для быстрого перемещения по огромной сцене.