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

При создании интерактивных элементов, таких как кнопки или перетаскиваемые объекты, разработчики часто используют метод `addListener`. Однако, если вызывать его каждый раз при обновлении состояния игры (например, в `create` без очистки), можно столкнуться с незаметной утечкой памяти — количество обработчиков событий будет неконтролируемо расти. Эта статья разбирает пример кода, демонстрирующий проблему, и объясняет, почему важно управлять жизненным циклом слушателей событий для стабильной работы игры.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    constructor()
    {
        super({
            key: "example"
        });
    }

    preload ()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('button', 'assets/sprites/button-bg.png');
    }

    create ()
    {
        this.button_click_index = 0;
        this.buttons = [];

        const button1 = this.add.image(400, 100, 'button')
            .setScale(0.5)
            .setInteractive({ draggable: true })
            .addListener('pointerdown', () => 
            {
                this.button_click_index = 0;
                console.log(this.button_click_index);
            })
            .addListener('drag',
                (_pointer, _dragX, dragY) =>
                {
                    this.drag(_pointer,
                        _dragX,
                        dragY)
                });
        this.buttons.push(button1);

        const button2 = this.add.image(400, 200, 'button')
            .setScale(0.5)
            .setInteractive({ draggable: true })
            .addListener('pointerdown', () => 
            {
                this.button_click_index = 0;
                console.log(this.button_click_index);
            })
            .addListener('drag', (
                _pointer,
                _dragX,
                dragY) =>
            {
                this.drag(_pointer,
                    _dragX,
                    dragY)
            });

        this.buttons.push(button2);
    }

    drag (_pointer, _dragX, dragY)
    {
        for (let i = 0; i < this.buttons.length; i++)
        {
            this.buttons[ i ].setY(dragY + i * 200);
        }
    }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#1d1d1d',
    parent: 'phaser-example',
    scale: {
        mode: Phaser.Scale.FIT,
        autoCenter: Phaser.Scale.CENTER_BOTH
    },
    scene: [ Example ]
};

const game = new Phaser.Game(config);

В чём заключается проблема?

В предоставленном примере в методе create сцены создаются две кнопки. Каждой кнопке через метод addListener добавляются обработчики событий pointerdown и drag. Код выглядит логичным для однократной инициализации.

Однако, представьте ситуацию, когда метод create может быть вызван повторно (например, при перезапуске сцены). В этом случае, новые обработчики будут добавлены к уже существующим объектам или будут созданы новые объекты со своими обработчиками, а старые не будут удалены. Это приводит к "накоплению" слушателей: одно действие (клик или перетаскивание) будет вызывать несколько одинаковых функций, что может вызвать падение производительности, неожиданное поведение и, в конечном счёте, утечку памяти.

// Проблемный паттерн: addListener в create без удаления
const button1 = this.add.image(400, 100, 'button')
    .setInteractive({ draggable: true })
    .addListener('pointerdown', () => { /* логика */ }); // Слушатель добавляется
// Если create вызвать снова, у button1 будет уже ДВА слушателя 'pointerdown'.

Разбор примера: интерактивность и события

Давайте посмотрим, как в примере реализована интерактивность.

Сначала объект делается интерактивным с возможностью перетаскивания с помощью setInteractive({ draggable: true }). Затем, метод addListener вручную подписывает объект на конкретные события. Это альтернатива использованию on или прямому назначению через this.input.on. Важно понимать, что addListener — это метод экземпляра GameObject, и он добавляет обработчик во внутренний массив этого конкретного объекта.

// Создание кнопки с интерактивностью и слушателями
const button1 = this.add.image(400, 100, 'button')
    .setScale(0.5)
    .setInteractive({ draggable: true }) // Включаем интерактивность и драг
    .addListener('pointerdown', () => { // Слушатель для нажатия
        this.button_click_index = 0;
        console.log(this.button_click_index);
    })
    .addListener('drag', (_pointer, _dragX, dragY) => { // Слушатель для перетаскивания
        this.drag(_pointer, _dragX, dragY);
    });

Метод drag обновляет позицию обеих кнопок, создавая связанное движение, что является отличным примером группового управления объектами.

Правильное управление слушателями событий

Чтобы избежать проблемы накопления слушателей, нужно явно управлять их жизненным циклом. Есть несколько подходов.

**1. Использование once вместо addListener.** Если событие должно сработать только один раз, используйте метод once. Он автоматически удалит слушатель после первого вызова.

**2. Удаление слушателей в shutdown или destroy.** При перезапуске или уничтожении сцены следует очищать все созданные в ней слушатели. Для этого можно использовать метод removeListener.

// Пример удаления слушателя (если сохранили ссылку на функцию)
const onPointerDown = () => { console.log('Клик!'); };
button.addListener('pointerdown', onPointerDown);
// ... позже, при очистке:
button.removeListener('pointerdown', onPointerDown);

**3. Использование системы событий сцены или ввода.** Часто более удобно и безопасно использовать встроенные менеджеры событий, такие как this.input.on. Их обработки часто автоматически очищаются при завершении работы сцены.

// Альтернатива: использование this.input.on для глобальных событий объекта
this.input.on('gameobjectdown', (pointer, gameObject) => {
    if (gameObject === button1) {
        // Обработка клика
    }
}, this);

**4. Пересоздание объектов.** Иногда проще уничтожить старые интерактивные объекты (вызвав destroy()) и создать новые в методе create, чем отслеживать и удалять каждый слушатель.

Практическая рекомендация: паттерн для перезапускаемых сцен

Для сцен, которые могут перезапускаться (например, игровые уровни), рекомендован следующий паттерн:

1. **Храните ссылки на объекты и их обработчики.** 2. **В методе shutdown (или в начале create перед созданием новых объектов) выполняйте очистку.**

class Example extends Phaser.Scene {
    constructor() {
        super({ key: "example" });
        this.buttons = []; // Массив для хранения кнопок
    }

    create() {
        // Очистка перед созданием (на случай рестарта)
        this.cleanup();

        // Создание кнопок...
        const button1 = this.add.image(400, 100, 'button')
            .setInteractive({ draggable: true });
        // Добавление слушателей...
        this.buttons.push(button1);
    }

    shutdown() {
        // Явная очистка при завершении работы сцены
        this.cleanup();
    }

    cleanup() {
        // Уничтожаем все кнопки. При вызове destroy()
        // Phaser автоматически удалит все связанные слушатели.
        for (let button of this.buttons) {
            button.destroy();
        }
        this.buttons.length = 0; // Очищаем массив
    }
}

Этот подход гарантирует, что при каждом новом вызове create вы начинаете с чистого листа.

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

Неконтролируемое добавление слушателей событий — частая и коварная ошибка, которая может долго оставаться незамеченной, пока не приведёт к ощутимому падению производительности. Ключевой вывод: всегда соотносите время жизни слушателя события с временем жизни объекта или сцены, которая его добавила. Для экспериментов попробуйте: 1. Создать сцену с кнопкой, которая перезапускает саму себя, и добавить счётчик вызовов обработчика pointerdown. 2. Реализовать систему «отписки» от событий для сложных UI-компонентов. 3. Сравнить производительность сцены с 50 объектами, где слушатели добавляются заново при каждом обновлении, и сцены, где слушатели управляются правильно.