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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene 
{
    constructor()
    {
        super();
    }

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

    create() {
        this.add.text(20, 20, "Will stop scene in ⏱ 1 s …")

        const i1 = this.add.image(160, 160, "block");
        const i2 = this.add.image(160, 260, "block");
        const i3 = this.add.image(160, 360, "block");

        i2.on("destroy", () => {
            i1.destroy();
        });

        this.time.delayedCall(1000, () => { this.scene.stop() })
    }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#2d2d2d',
    parent: 'phaser-example',
    scene: Example
};

const game = new Phaser.Game(config);

Проблема: Ошибка при цепной реакции уничтожения

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

На второе изображение (i2) навешивается обработчик события "destroy". Когда i2 уничтожается, этот обработчик должен уничтожить первое изображение (i1). Проблема возникает, когда сцена останавливается методом this.scene.stop().

При остановке сцены Phaser автоматически уничтожает все объекты, принадлежащие этой сцене. Процесс уничтожения i2 запускает наш пользовательский обработчик, который пытается уничтожить i1. Но i1 в этот момент уже может быть в процессе уничтожения системой Phaser, что приводит к внутреннему конфликту и ошибке.

Анализ исходного кода

Давайте внимательно посмотрим на код метода create, где заложена проблема.

create() {
    this.add.text(20, 20, "Will stop scene in ⏱ 1 s …")

    const i1 = this.add.image(160, 160, "block");
    const i2 = this.add.image(160, 260, "block");
    const i3 = this.add.image(160, 360, "block");

    i2.on("destroy", () => {
        i1.destroy();
    });

    this.time.delayedCall(1000, () => { this.scene.stop() })
}
Ключевые моменты:
1.  Создаются три спрайта с помощью `this.add.image`.
2.  Для `i2` регистрируется слушатель на собственное событие `"destroy"`. Это событие генерируется самим Phaser в момент вызова метода `destroy()` для объекта.
3.  Через секунду выполняется `this.scene.stop()`. Этот метод инициирует процесс остановки и очистки сцены.

Почему возникает ошибка?

Метод this.scene.stop() не просто ставит сцену на паузу. Он выполняет последовательность шагов, включая уничтожение всех игровых объектов, созданных через фабрики сцены (как this.add.image).

Когда система Phaser начинает чистить сцену, она в определенном порядке вызывает destroy() для каждого объекта. Если в этот момент один из уничтожаемых объектов (в нашем случае i2) запускает дополнительную логику, которая также пытается уничтожить другой объект (i1), система может оказаться в противоречивом состоянии. Объект i1 может быть уже помечен для удаления или частично очищен, и повторный вызов destroy() для него приводит к внутренней ошибке движка (ошибка валидации, которая в примерах отображается как "bugs/7045 error stopping scene.js").

Главный вывод: **нельзя полагаться на ручное уничтожение объектов внутри обработчиков событий "destroy", если эти объекты принадлежат той же сцене, которая в данный момент останавливается или перезапускается.**

Решение: Безопасные паттерны очистки

Как же правильно организовать очистку связанных объектов? Вот несколько подходов.

**1. Использование событий самой сцены.** Лучше всего слушать событие "shutdown" или "destroy" у самой сцены. Эти события генерируются до того, как Phaser начнет автоматически удалять объекты.

create() {
    // ... создание объектов ...
    const i1 = this.add.image(160, 160, "block");
    const i2 = this.add.image(160, 260, "block");

    // Вместо подписки на destroy объекта - подписываемся на shutdown сцены
    this.events.once('shutdown', () => {
        // Здесь можно безопасно разрывать связи между объектами,
        // останавливать таймеры, отписываться от событий.
        // Вызывать i1.destroy() или i2.destroy() НЕ НУЖНО.
        console.log('Сцена завершает работу. Объекты будут удалены автоматически.');
    });

    this.time.delayedCall(1000, () => { this.scene.stop() });
}

**2. Управление жизненным циклом вручную (для сложных случаев).** Если вам критично контролировать порядок уничтожения, можно отключить автоматическую очистку и сделать всё самим перед остановкой сцены.

create() {
    // ... создание объектов ...
    const i1 = this.add.image(160, 160, "block");
    const i2 = this.add.image(160, 260, "block");

    this.time.delayedCall(1000, () => {
        // 1. Сначала ручная очистка в нужном порядке
        i1.destroy();
        i2.destroy();
        // 2. Только потом остановка сцены
        this.scene.stop();
    });
}

В этом случае, так как вы сами вызвали destroy() до this.scene.stop(), автоматическая очистка для этих объектов не произойдет, и конфликта не будет.

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

Остановка сцены в Phaser — мощный, но требующий аккуратности инструмент. Основное правило: избегайте вызова destroy() для игровых объектов из обработчиков событий "destroy" других объектов, если эти объекты принадлежат останавливающейся сцене. Для кастомной логики очистки используйте события сцены shutdown или выполняйте ручное удаление до вызова scene.stop(). **Идеи для экспериментов:** 1. Создайте группу (this.add.group()) связанных спрайтов и попробуйте очистить её в обработчике shutdown с помощью метода group.clear(true, true). 2. Проверьте, как ведут себя физические тела (physics.add.sprite) при остановке сцены. Нужно ли отключать для них коллизии вручную? 3. Реализуйте перезапуск сцены (this.scene.restart()) и убедитесь, что всякая кастомная очистка в shutdown работает корректно.