О чем этот пример
Разработка кроссплатформенной игры на 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, на реальных мобильных устройствах, ведь эмуляторы могут не воспроизводить такие тонкие баги.
