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

Разработка кроссплатформенной игры на Phaser — это постоянная борьба с особенностями разных браузеров. Особенно коварны мобильные устройства, где взаимодействие между нативными и DOM-элементами может привести к неожиданным багам. В этой статье мы разберем конкретный случай с iOS, когда обработчик событий на текстовом объекте перестает работать после манипуляций с DOM-элементом. Понимание этой проблемы критически важно для разработчиков, использующих гибридный подход с DOM (для сложных UI-форм, встраивания видео или интеграции с веб-компонентами). Мы не только найдем причину бага в примере из репозитория Phaser, но и предложим рабочие обходные пути.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene {
    constructor() {
        super({ key: 'Example' });
    }
    create() {
        let textbutton = this.add.text(50, 50, "Text", { font: '64px Courier' });
        textbutton.setInteractive();
        let self = this;
        let x = 0;
        textbutton.on('pointerup', function (event) {
            self.add.text(100, 100 + 50 * x, "inserted", { font: '64px Courier' });
            x++;
            // el.destroy()    //does not cause the issue either
        });
        var div = document.createElement('div');
        div.setAttribute("style", "color: white; font: 48px Arial;");
        div.innerText = "DOM Element";
        let el = this.add.dom(500, 80, div);
        //after the following event handler is called, the event handler for "textbutton" above does not get called any further
        div.addEventListener('pointerup', function (event) {
            textbutton.text = 'boom';
            el.destroy();
            // div.remove() //causes the same issue
        });
        // el.destroy()  //if "el" is destroyed outside of event handler, the issue does not arise
    }
}
const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#220000',
    parent: 'phaser-example',
    scene: [Example],
    dom: {
        createContainer: true
    },
};
const game = new Phaser.Game(config);

Суть проблемы: немой текст на iOS

В предоставленном примере создается сцена с двумя интерактивными элементами: 1. Текстовый объект Phaser (textbutton), при клике на который добавляется новый текст на сцену. 2. DOM-элемент (div), добавленный в Phaser через this.add.dom(), при клике на который меняется текст кнопки и уничтожается сам DOM-контейнер.

На десктопных браузерах всё работает как часы. Однако на iOS (и иногда на других мобильных платформах) возникает специфичный баг: **после клика на DOM-элемент текстовый объект Phaser полностью перестает реагировать на события pointerup**. Клики по нему больше не добавляют надписи "inserted".

Ключевая деталь: проблема возникает только если DOM-элемент уничтожается (el.destroy()) или удаляется (div.remove()) **внутри своего же обработчика события**. Если уничтожить его сразу в create(), баг не проявляется.

Заглянем под капот: фазы событий и фокус ввода

Чтобы понять причину, нужно вспомнить, как Phaser и браузер обрабатывают события касания/клика.

События на мобильных устройствах (особенно в Safari) часто имеют две фазы: 1. **Нативная (DOM) фаза**: Событие проходит через дерево DOM. 2. **Фаза Canvas**: Если событие не было «перехвачено» или остановлено в DOM, Phaser может его обработать для объектов внутри Canvas.

Когда вы вызываете el.destroy() внутри обработчика DOM-элемента, Phaser удаляет DOM-узел из контейнера игры. На iOS это действие может сбить с толку систему событий браузера. Указатель (палец) был «захвачен» DOM-элементом, который внезапно исчез, что может привести к тому, что событие pointerup для других объектов (включая наш textbutton) не будет корректно завершено или отправлено.

Проще говоря, уничтожение цели события в процессе его обработки нарушает жизненный цикл события на iOS.

// Проблемный код внутри обработчика DOM-элемента:
div.addEventListener('pointerup', function (event) {
    textbutton.text = 'boom';
    el.destroy(); // <- Опасная операция на iOS!
});

Решение 1: Отложенное уничтожение (setTimeout)

Самый простой и надежный способ обойти проблему — вынести деструктивные операции за пределы синхронного потока выполнения обработчика события. Для этого идеально подходит setTimeout с нулевой задержкой.

Этот прием передает выполнение el.destroy() в следующую задачку цикла событий браузера. К тому моменту, когда она выполнится, событие pointerup уже будет полностью обработано для всех элементов, и его жизненный цикл не нарушится.

div.addEventListener('pointerup', function (event) {
    textbutton.text = 'boom';
    // Переносим уничтожение в асинхронную очередь
    setTimeout(() => {
        el.destroy();
    }, 0);
});

Такой подход гарантирует, что DOM-элемент исчезнет сразу после клика, но безопасно для системы событий iOS.

Решение 2: Флаг «неактивности» вместо уничтожения

Если логика позволяет, иногда лучше не уничтожать элемент, а просто скрывать или делать его неинтерактивным. Это полностью избегает проблем с перехватом событий.

В Phaser у DOM-элемента, добавленного через this.add.dom(), есть свойство visible и методы для управления интерактивностью. Вы можете просто скрыть элемент, а уничтожить его позже, в более безопасном контексте (например, при переходе между сценами).

div.addEventListener('pointerup', function (event) {
    textbutton.text = 'boom';
    // Вместо destroy() — скрываем и отключаем
    el.setVisible(false);
    el.removeInteractive(); // Если элемент был интерактивным
    // Можно также очистить содержимое
    div.innerText = '';
    // А destroy() вызвать, например, в update() при проверке флага
    // this.markedForDestruction = el;
});

Этот способ более предсказуем и сохраняет производительность, если создание/уничтожение DOM-элементов происходит часто.

Решение 3: Единая точка входа для событий через Phaser

Идеологически чистое решение — минимизировать прямое взаимодействие с нативными DOM-событиями. Phaser предоставляет мощную систему событий для своих DOM-элементов через метод addListener. События, обработанные через движок Phaser, могут быть более безопасными.

Хотя в данном конкретном примере проблема может сохраниться (так как destroy() все равно вызывается внутри обработчика), этот подход создает более контролируемую среду. Вы можете централизованно управлять событиями и применять обходные пути (как Solution 1) в одном месте.

// Вместо div.addEventListener используем:
el.addListener('pointerup');
el.on('pointerup', function (event) {
    textbutton.text = 'boom';
    // Здесь тоже стоит использовать setTimeout
    let targetEl = this;
    setTimeout(() => {
        targetEl.destroy();
    }, 0);
});

Работа через систему событий Phaser улучшает архитектуру кода и облегчает отладку.

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

Баг с «засыпанием» событий на iOS после уничтожения DOM-элемента — яркий пример специфики мобильных браузеров. Основной вывод: **избегайте синхронного уничтожения или удаления DOM-узлов непосредственно внутри их же обработчиков событий на мобильных платформах.** Используйте setTimeout для отложенного выполнения или заменяйте уничтожение скрытием. Для экспериментов: 1. Проверьте, проявляется ли тот же баг на Android-устройствах в разных браузерах. 2. Попробуйте использовать событие pointerdown вместо pointerup — иногда разница в фазах события дает другой результат. 3. Исследуйте, помогает ли вызов event.stopPropagation() или event.preventDefault() в обработчике DOM-элемента предотвратить проблему. Всегда тестируйте интерактивность, затрагивающую DOM, на реальных мобильных устройствах, ведь эмуляторы могут не воспроизводить такие тонкие баги.